Project memory for future Claude sessions. Not user-facing — see README.md
for that.
CLI that produces a self-contained static-site zip of an fxhash author's articles (or a collector's holdings), ready to drop onto Cloudflare Pages, Netlify, GitHub Pages, etc. All upstream data is fetched from public APIs; no auth.
The "output format" mirrors Matters.town's matters-lifeboat/v1 zip
(index.html + posts/ + assets/ + style.css + manifest.json). That
was the user-provided reference. Keep that shape if extending.
fxhash_backup.py single-file CLI (only source file)
requirements.txt just `markdown` (pure-python md→HTML)
README.md user-facing docs (EN, zh-TW friendly)
LICENSE MIT
.gitignore excludes *.zip, .venv, etc.
.github/workflows/backup.yml workflow_dispatch with username + options
→ uploads zip as artifact
GitHub: https://github.com/javaing/fxhash-articles-backup
- Fetch article stubs —
gql_user_articles(name)forauthormode,gql_user_owned_articles(name)forowned, both deduped forall. - Fetch each article body —
gql_article(slug)with the canonical field set. - Download IPFS assets — three sub-passes via
fetch_to_assets(): a. cover thumbnails (thumbnailUri), b. inline ipfs:// references in body (viaIPFS_REF_RE), c. Tezos NFT embed thumbnails (resolved through fxhash GraphQL for the issuer contractKT1BJC12dG17CVvPKJ1VYaNnaT5mzfnUTwXv, otherwise via TzKThttps://api.tzkt.io/v1/tokens). - Render HTML + write manifest — markdown→HTML with custom directive
placeholders, rewrite ipfs:// to local
assets/...paths (or to a gateway URL if no local copy), buildindex.html, writemanifest.json, zip everything.
Each flag is opt-in; nothing else changes from default behavior.
| Flag | Default | Effect | Code |
|---|---|---|---|
<username> (positional) |
required | fxhash user, e.g. aquaponics.kana (spaces OK, quote it) |
argparse in main() |
-o/--output-dir DIR |
. |
where to write the zip | main() |
-l/--lang {auto,zh-Hant,en} |
auto |
site UI language; auto = detect_lang() based on CJK char density in bodies |
STRINGS dict |
-x/--exclude SLUG (repeatable) |
none | drop a specific article. Accepts raw slug, slugified-filename stem, or full {date}-{stem} (paste from URL works) |
article_matches_exclude() |
-m/--mode {author,owned,all} |
author |
which articles to fetch | section above |
--max-asset-size SIZE |
none (no cap) | skip downloading any single IPFS asset bigger than SIZE; HTML falls back to cloudflare-ipfs.com/ipfs/.... Accepts 25M, 30MB, 1500000, etc. |
AssetTooLarge class + fetch_ipfs(max_bytes=) |
--insecure |
off | disables TLS verify (corp MITM proxy). Sets ssl._create_default_https_context = ssl._create_unverified_context |
main() |
Output zip name:
authormode:<username>-fxhash.zipowned:<username>-owned-fxhash.zipall:<username>-all-fxhash.zip
Bodies contain non-standard ::name[]{attr="value"} directives. Strategy:
stash with placeholder → run md→HTML → swap placeholder back in. See
md_to_html(). Each directive needs its own stash_xxx step.
Current handlers:
::tezos-storage-pointer[]{contract="…" path="ledger::N" / "token_metadata::N"}— resolved during phase 3 intoNFT_INFO[(contract, token_id)], rendered bymake_render_tezos_pointer()as a clickable thumbnail figure.::video[]{src="…"}— rendered as<video controls playsinline>. The src may be anipfs://URI; rewriting to local/gateway happens viarewrite_ipfs_in_md()BEFORE md→HTML.::embed-media[]{href="…"}— rendered as a labelled link card. Platform detection inrender_embed_media(): Twitter/X, YouTube, Spotify, Vimeo, Instagram, TikTok, generic. Tweet labels extract@usernamefrom the URL path.- Generic fallback — any other
::xxx[]{…}directive renders as a plain "📦 …" card so unknown directives never leak raw markup.
Two languages: zh-Hant and en. All chrome strings live in STRINGS
dict at module top. Per-mode title/hero variants:
site_title / site_title_owned / site_title_all. Adding a language
= add a new key in STRINGS, AGENT_TMPL, SITE_README_TMPL, and add it
to the CLI --lang choices.
detect_lang() heuristic: counts CJK code points vs ASCII letters in
concatenated bodies. Rough but works.
asset_map[ipfs_uri] = "assets/<prefix>-<sha1[:10]>.<ext>". Prefix isthumb-,img-, ornft-<token_id>-.- Sha1 of the full
ipfs://CID/pathis the dedupe key — same URI in two articles = one file. - Extension comes from
Content-Typeviaext_from_ct()..jpeis normalized to.jpg. oversize_uris: set[str]tracks size-cap skips. But network failures go through a separateexcept Exceptionbranch and aren't counted here — they fall back to gateway URLs the same way, butoversize-skippedin the final stats only counts true cap hits.- Cap is enforced first via
Content-Length, then by streamed read with a 64 KB chunk loop (covers gateways that don't send Content-Length). - IPFS gateways tried in order:
nftstorage.link,cloudflare-ipfs.com,ipfs.io,<cid>.ipfs.dweb.link,<cid>.ipfs.cf-ipfs.com. The HTML gateway fallback always usescloudflare-ipfs.com.
| Purpose | URL | Notes |
|---|---|---|
| Articles, users, fxhash projects | https://api.fxhash.xyz/graphql (V3) |
What this tool uses. articles(take:N) capped at take<=50. |
| Multichain / V4 fxhash data | https://api.v2.fxhash.xyz/v1/graphql |
Not used — V3 covers Tezos-only article system. V4's search endpoints are flaky / blocked. |
| Generic Tezos NFT metadata | https://api.tzkt.io/v1/tokens?contract=…&tokenId=… |
Used for non-fxhash NFT embeds. |
PAGE_SIZE = 50 for paginated article fetching.
aquaponics.kana— Chinese-language author, 6 articles, exercises thetezos-storage-pointerdirective heavily (~50 NFT embeds).Strider5071— English author, 3 articles, small (~1.6 MB zip). Good for fast smoke tests.arttseng(the project maintainer) — large--mode alltest: ~80 articles, ~1.4 GB unbounded / ~300 MB with--max-asset-size 25M.Elout de Kok— exercises slug with apostrophes for--exclude.
Add a new markdown directive. Add a regex XXX_DIR_RE, a
render_xxx_directive(m) function, a stash_xxx step inside
md_to_html(). Style its output via a new CSS class in STYLE_CSS. If
its src/href references ipfs://, you don't need to download separately —
rewrite_ipfs_in_md() runs first and rewrites any ipfs:// inside the body
text.
Add a new language. Extend the STRINGS / AGENT_TMPL /
SITE_README_TMPL dicts with a new key. Add it to the --lang argparse
choices=[...]. Tweak detect_lang() if auto-detection should pick it.
Add a new Tezos NFT thumbnail source. Branch in the for contract, token_id in pointers: loop — currently it special-cases
FXHASH_ISSUER_V2 and falls through to TzKT. Add additional contract IDs
above the TzKT fallback as needed.
Tweak embed-media platforms. All in render_embed_media() —
hostname-prefix matching + emoji + label string.
- Windows console codepage. stdout may be cp950/cp1252; emoji and
some CJK fail to encode. Use
safe_print()everywhere, not bareprint(). It catchesUnicodeEncodeErrorand writes a sanitized copy. - Corporate TLS MITM. Maintainer's network re-signs HTTPS with an
untrusted CA. Python 3.14 rejects with
Basic Constraints of CA cert not marked critical.--insecureflag works around this. Also affectsgit push— usegit -c http.sslVerify=false push. Memory in~/.claude/projects/.../MEMORY.mddocuments this. - IPFS gateway truncation. Some public gateways truncate at 30 MiB
silently — files come back exactly
31457280bytes with seemingly correct Content-Length. Treat any asset exactly 30 MiB as potentially corrupt. - fxhash issuer vs gentk contracts.
KT1BJC12dG17CVvPKJ1VYaNnaT5mzfnUTwXvis the fxhash issuer (one ID = one project, queried via fxhash GraphQL).KT1U6EHmNxJTkvaWJ4ThczG4FSDaHC21ssviis GENTK v2 (one ID = one minted edition, queried via TzKT). Embeds can point at either; the conditional in the NFT-metadata loop distinguishes them. takecapped at 50. fxhash V3 GraphQL rejectstake > 50. The paginator just loops withskipuntil short page.- Slug edge cases. Slugs may contain
(,),.,'. Filenames go throughslugify_filename()which strips these;--excludematches against multiple normalized forms so users can paste either form.
- Cloudflare Pages — 25 MB per file cap. Use
--max-asset-size 25M. Upload via "Upload assets" (not the Git path) to skip build steps. - Netlify / Vercel static / GitHub Pages — no per-file cap to worry about; unzip and use repo root as publish dir.
No tags / releases yet. Just main. Keep commit messages descriptive
(Add --xxx flag: … style) because that's the only changelog.