- warhammer.com shop HTML/listing endpoints are behind AWS WAF (405/202 goku challenge) for non-browser clients; findings recorded in AGENTS.md - the shop search runs on Algolia; public search-only key lets us dump the whole product index with plain requests (no browser, no captcha) - gwshop.py: credential loading (env or gitignored algolia.json), index listing, facet-partitioned full retrieval (search-only key cannot /browse and pagination caps at 1000, so the index is partitioned on the GameSystemsRoot hierarchy facet), normalization into the sorter's source-agnostic catalog schema (system/group/faction/subfaction/class) - data/gwshop_catalog.json: snapshot of prod-lazarus-product-en-ww, 3491 unique products, every product with full hierarchy path - tools/: one-shot browse + catalog builder + shape inspector
12 KiB
AGENTS.md — Warhammer_DLP_Helper
Guidance for any AI agent (or human) working in this repository. Read this fully before making changes.
1. What this project is
The user collects Warhammer proxy models (3rd-party / fan sculpts, never official Games Workshop assets) that are shared in Discord channels. Links in those channels point to two hosters and are often base64-encoded inside message content (obfuscated by the posting bot).
The end goal is a torrent-first platform (think radarr/lidarr, but for proxy files). This repo is the current stage of that journey: a tool that
- exports a Discord channel/thread (DiscordChatExporter via docker),
- extracts base64-encoded and plain download links,
- picks the right downloader per host automatically,
- downloads everything with per-file tracking, retries and resume,
- records a manifest that will later feed the torrent stage.
Deliberately not in scope (yet): .torrent creation, tracker upload,
deseeding, rename-into-library conventions. Design decisions should not close
the door on these — especially manifest.jsonl and the --hash option exist
for dedupe/cross-seeding later.
2. Repository & environment
- Remote:
git.dieterunterstrich.de/Ben_Kosytorz/Warhammer_DLP_Helper— private repo, branchmain. Push with the credentials already stored via git's credential store (~/.git-credentials); never paste them into commands or files. - The server is Gitea 1.27. To create another repo, use
python3 tools/new_repo.py list|create <name>(reads~/.git-credentialsitself, never prints secrets). Never delete or overwrite existing repos. - Machine: Linux, Python 3.12+ (3.14 available),
requestsis the only runtime dependency (seerequirements.txt), docker is available. - Reference clone:
~/GoFileDownloader(Lysagxra) — do not modify it; it is upstream reading material only.
3. Architecture
dlp.py CLI orchestrator (entry point)
catalogue.py CLI: classify the collection into system/faction/class
dlphelper/
discord.py DCE docker export (JSON) + base64/plain URL extraction
gofile.py GoFile API client (one guest account per run)
zero807.py curl-based downloader for 0807.st direct links
state.py state.json store + manifest.jsonl appender
globalstore.py cross-run URL store (~/.local/share/dlphelper/urls.json)
catalogue.py scanner + keyword classifier + report writer
warhammer_data.py taxonomy: systems, factions, force-org classes
parts/grab.sh legacy groundstone script (kept, not used by dlp.py)
tools/new_repo.py Gitea repo helper (reads ~/.git-credentials itself)
tools/debug_gofile.py inspect raw gofile API response for one content id
tests/test_extract.py offline extraction test (plain python3, no deps)
tests/test_catalogue.py offline classifier test
Data flow:
graph LR
A[channel id + token] --> B[DCE docker export -> export.json]
B --> C[decode base64 in message content]
C --> D{classify host}
D -->|gofile.io| E[GoFileClient: 1 guest account, list contents, download files]
D -->|0807.st| F[curl with UA + Referer]
D -->|other| G[recorded as failed: unsupported host]
E --> H[downloads/gofile/...]
F --> I[downloads/0807/...]
E & F --> J[state.json + manifest.jsonl + failed_urls.txt]
J --> K[globalstore: urls.json, marks URL done forever]
Key facts about the hosters (learned the hard way, do not re-learn):
- gofile.io: guest accounts are created via
POST api.gofile.io/accounts. Creation is aggressively rate-limited — create at most one account per run. Content listing needsAuthorization: Bearer <token>, anX-Website-Tokenheader (sha256 overUA::en-US::<token>::<4h-window>::seed, seegofile.py), and downloads needCookie: accountToken=<token>plus Referer/Origin headers. - 0807.st: serves files directly but 403s anything that doesn't look like
a browser.
curl -A "Mozilla/5.0" -e "https://0807.st/"works; aria2c's default multi-connection request does not. curl is the tool here. - Encoded links in the channels start as base64 strings matching
aHR0cHM6Ly[89]...— keep the extraction regex compatible withparts/grab.sh.
4. Usage (what "done" looks like)
export DISCORD_TOKEN=...
python3 dlp.py <channel_id> /big/storage/warhammer/<channel-name>
Retry only failures: python3 dlp.py <id> <out> --urls-file <out>/failed_urls.txt
(or just re-run — state.json skips everything already marked ok).
Several channels at once (subscriptions.json: list of {name, channel_id,
out_dir}):
python3 dlp.py --subs subscriptions.json
Subs mode re-exports every channel by default (that is how new links get picked up) and relies on state.json + the global store to only download what is actually new. Use a cron/systemd timer for watch-like behavior.
Manual import of the pre-dlp.py era (URLs you know are fully downloaded, never re-fetch them again):
python3 dlp.py --assume-done old_urls.txt
5. Run artifacts (all in OUT_DIR, gitignored)
| File | Purpose |
|---|---|
export.json |
DCE export; cached — --fresh re-exports |
state.json |
url → {status, attempts, error, files[]}; corrupt files are backed up and reset |
failed_urls.txt |
rewritten each run: every non-ok URL, usable as --urls-file |
manifest.jsonl |
append-only, one JSON line per downloaded file (channel, url, message_id, timestamp, author, path, size, optional sha256) |
downloads/ |
actual content; .part files mean "interrupted, will be resumed" |
~/.local/share/dlphelper/urls.json |
global cross-run store: every successfully downloaded URL is recorded once, regardless of OUT_DIR, so reposts in other channels are not re-downloaded. --global-state moves it, --no-global disables it for a run. |
6. Rules
Secrets and tokens
- Never print, log, commit or echo a Discord token or GoFile account token. Not even partial. Strip them from any output you paste into chats.
- The Discord token goes:
--tokenflag →$DISCORD_TOKEN→ hidden prompt. It is handed to the DCE container via theTOKENenv var, never as a command-line argument. ~/.git-credentialsmay only be read bytools/new_repo.py. Never copy credentials into scripts, tests, or fixtures.- Nothing downloaded and no export/state/manifest file is ever committed.
Failure policy (the core requirement)
- A failure in one URL or file must never abort the run. Catch, record in
state.json, continue. The loops indlp.pytherefore have catch-alls — keep them. - Cooldowns are the only permitted "stall": after N consecutive failures, pause, then continue. Circuit-breakers that exit the process need a explicit justification in the PR/commit message.
- Retries: exponential-ish (
retry_delay * attempt), default 3 attempts. Password-required gofile links are not retried (it can't help).
Idempotency
- Re-runs must be safe and cheap: skip URLs marked
ok(instate.jsonor the global store), skip existing non-empty files, write partial downloads to*.partand rename on success. state.jsonis the source of truth for resume; keep its schema backward compatible when touchingstate.py(unknown keys are preserved, missing keys defaulted). Same for the global store schema inglobalstore.py.--assume-doneis a deliberate, manual import step for old URL lists. Never invoke it automatically, and never let the tool mark URLs done without a verified successful download.
Rate limiting / network etiquette
- One gofile guest account per run;
ensure_token()backs off linearly (20s × attempt). Do not raise the guest-account frequency "to make tests pass". Testing account creation more than a couple times per hour is a bug, not a test strategy. - Keep the 0807 referer/UA contract. Don't "optimize" it to aria2c without re-verifying the 403 behavior.
Code style
- Python 3.12+, type hints on public functions,
loggingnotprintin library code (prints are fine only for--dry-runoutput). - Stay on stdlib +
requests. New dependencies need a real justification and belong inrequirements.txt. dlphelper/modules must stay import-safe (no side effects at import).
Validation
- Minimum validation for any change:
python3 -m py_compile dlp.py catalogue.py dlphelper/*.py, plus the offline tests (python3 tests/test_extract.py,python3 tests/test_catalogue.py) if the touched modules changed. Document what you could not test (e.g. live gofile/0807 downloads need real links and tokens). - Full live runs need a Discord token from the user — ask for it, never ask for it to be pasted into a file or committed.
Git
- Branch
main, descriptive commit messages, no force-push, no history rewriting on the shared remote. - When creating a new repo use
tools/new_repo.py(Gitea API); creation is fine, deletion/renaming of existing repos requires the user.
7. Roadmap (future stages, keep compatible)
-
Dedupe: hash-based (sha256) duplicate detection across threads via
manifest.jsonl(the--hashflag feeds this). URL-level dedupe already exists via the global store; content-level (same file, different link) comes with hashing. -
Torrent stage:
mktorrentper downloaded folder, .torrent files into a watch dir, cross-seeding between direct-download and torrent copies. -
Library organization (started):
catalogue.pyclassifies every file into system / faction / force-org class and can move files into<library>/<System>/<Faction>/<Class>/with--apply(audited incatalogue_moves.jsonl). The taxonomy lives indlphelper/warhammer_data.py; it is keyword-based and grows with the collection - unclassified files are reported, never guessed. Manual fixes goes into an overrides JSON (--overrides).Catalog source findings (warhammer.com, verified 2026-10-04, do not re-probe blindly): the shop HTML and all search/listing endpoints (
/en-WW/shop/*,/app/resources/catalog/search/*,/app/resources/catalog/categories) sit behind an AWS WAF challenge (405/202 "goku" JS captcha) for any non-browser client, regardless of User-Agent. Static assets, robots.txt and 404 pages are served normally. The sitemap declared in robots.txt does not exist. Deep paths under the allowed/app/resources/catalog/product/*reach a separate commerce backend (plain404 Not found) but the correct resource shape is unknown. The site's product search is Algolia; its app-id and search-only key are public (they ship with the browser page) and would allow a full catalog dump via plainrequests— this is the most promising path: capture one search request from the browser devtools ("Copy as cURL") and builddlphelper/gwshop.pyon it. Catalog sources stay pluggable and source-agnostic regardless (miniset.net, manual JSON overrides, GW shop). -
Watch mode:
--subs subscriptions.json+ a cron/timer already gives a primitive version; a real watch mode (per-subscription schedules, tear-off notifications, discord pings on new links) builds on it.
8. Testing notes
tests/currently holds a syntheticexport.jsontest for extraction (tests/test_extract.py, runnable with plainpython3). Keep tests dependency-free and offline — they must never hit gofile/0807/Discord.- To exercise downloads without Discord: build a
--urls-filefrom known URLs and run with a scratch OUT_DIR.