commit 8bc2218079e25fed90710992f300f6fda195c85e Author: WMJ Date: Wed Aug 12 02:24:47 2026 +0200 gumroad-cli: Gumroad API v2 CLI (products/sales/followers/offer-codes/buyers-csv) diff --git a/README.md b/README.md new file mode 100644 index 0000000..4a0d25a --- /dev/null +++ b/README.md @@ -0,0 +1,23 @@ +# gumroad-cli + +CLI for the Gumroad API v2 (Python 3, stdlib only — no pip deps). + +## Setup +```bash +export GUMROAD_TOKEN= # or --token flag +``` + +## Commands +| Command | Description | +|---------|-------------| +| `products` | List all products (id, name) | +| `product ` | Product detail (price, description, custom fields) | +| `sales` | List sales (email, product, amount, date) | +| `followers` | List follower emails — **404: Gumroad API has no followers endpoint** (dashboard UI only) | +| `offer-codes` | List offer codes per product | +| `buyers-csv` | Export unique buyer emails to CSV (for Listmonk sync) | + +## Notes +- Gumroad store `alfalahtech.gumroad.com` is the **archived** store; active store = Polar on fi2.falahos.my/products. +- No API path exists to bulk-add followers/subscribers → Listmonk stays the source of truth for blasts. +- Buyer sync flow: `gumroad-cli buyers-csv` → import CSV into Listmonk. diff --git a/gumroad_cli.py b/gumroad_cli.py new file mode 100644 index 0000000..a22eb91 --- /dev/null +++ b/gumroad_cli.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +"""gumroad-cli — interface with the Gumroad API v2 (stdlib only). + +Commands: + products list all products (id, name, price, url) + product detail for one product + sales list recent sales (email, product, amount, date) + followers list follower emails (GET /v2/followers — 404 on current API) + offer-codes list offer codes per product + buyers-csv export all unique buyer emails to CSV +Token: env GUMROAD_TOKEN or --token flag. +""" +import argparse, csv, json, os, sys, time, urllib.parse, urllib.request + +API = "https://api.gumroad.com/v2" + +def api_get(path, params, token): + params["access_token"] = token + url = API + path + "?" + urllib.parse.urlencode(params) + try: + with urllib.request.urlopen(url, timeout=30) as r: + return json.loads(r.read().decode()) + except urllib.error.HTTPError as e: + return {"http_error": e.code, "body": e.read().decode()[:500]} + +def api_post(path, params, token): + params["access_token"] = token + data = urllib.parse.urlencode(params).encode() + req = urllib.request.Request(API + path, data=data, method="POST") + try: + with urllib.request.urlopen(req, timeout=30) as r: + return json.loads(r.read().decode()) + except urllib.error.HTTPError as e: + return {"http_error": e.code, "body": e.read().decode()[:500]} + +def money(cents): + return f"${cents/100:.2f}" if cents is not None else "-" + +def cmd_products(d, t): + r = api_get("/products", {}, t) + for p in r.get("products", []): + print(f"{p.get('id')}\t{money(p.get('price_cents'))}\t{p.get('name')}\t{p.get('url')}") + +def cmd_product(d, t): + r = api_get(f"/products/{d.id}", {}, t) + p = r.get("product", {}) + if not p: + print(json.dumps(r, indent=2)[:500]); return + print(f"ID: {p.get('id')}") + print(f"Name: {p.get('name')}") + print(f"Price: {money(p.get('price_cents'))}") + print(f"URL: {p.get('url')}") + print(f"Desc: {(p.get('description') or '')[:400]}") + print(f"Sales: {p.get('sales_count')}") + print(f"Custom fields: {[f.get('name') for f in p.get('custom_fields') or []]}") + +def cmd_sales(d, t): + r = api_get("/sales", {}, t) + for s in r.get("sales", []): + print(f"{s.get('created_at','')}\t{s.get('email','')}\t{s.get('product_name','')}\t{money(s.get('amount_cents'))}") + +def cmd_followers(d, t): + r = api_get("/followers", {}, t) + if "http_error" in r: + print(f"ERR {r['http_error']}: {r['body'][:200]}") + print("(Gumroad API v2 has no followers endpoint — followers exist only in the dashboard UI)") + return + for f in r.get("followers", []): + print(f.get("email", "")) + +def cmd_offer_codes(d, t): + r = api_get("/products", {}, t) + for p in r.get("products", []): + pid = p.get("id") + r2 = api_get(f"/products/{pid}/offer_codes", {}, t) + codes = r2.get("offer_codes", []) + if codes: + print(f"== {p.get('name')} ({pid})") + for c in codes: + amount = money(c.get("amount_cents")) if c.get("amount_cents") else f"{c.get('percent_off')}%" + print(f" {c.get('code')}\t{amount}\t{c.get('name')}") + +def cmd_buyers_csv(d, t): + emails = {} + page, total = 1, None + while True: + r = api_get("/sales", {"page": page, "per_page": 100}, t) + sales = r.get("sales", []) + for s in sales: + if s.get("email"): + emails[s["email"].strip().lower()] = True + total = r.get("total") + if not sales or (total is not None and page * 100 >= total) or page > 50: + break + page += 1 + out = d.out or "gumroad_buyers.csv" + with open(out, "w", newline="") as f: + w = csv.writer(f) + w.writerow(["email"]) + for e in sorted(emails): + w.writerow([e]) + print(f"Wrote {len(emails)} unique buyer emails -> {out}") + +def main(): + ap = argparse.ArgumentParser(prog="gumroad-cli", description="Gumroad API v2 CLI") + ap.add_argument("--token", default=os.environ.get("GUMROAD_TOKEN"), help="API token (env GUMROAD_TOKEN)") + ap.add_argument("--out", default=None, help="output file for buyers-csv") + sub = ap.add_subparsers(dest="cmd", required=True) + sub.add_parser("products") + sp = sub.add_parser("product"); sp.add_argument("id") + sub.add_parser("sales") + sub.add_parser("followers") + sub.add_parser("offer-codes") + bp = sub.add_parser("buyers-csv"); bp.add_argument("--out", default=None) + d = ap.parse_args() + t = d.token + if not t: + sys.exit("ERROR: no token. Set GUMROAD_TOKEN env or pass --token.") + {"products": cmd_products, "product": cmd_product, "sales": cmd_sales, + "followers": cmd_followers, "offer-codes": cmd_offer_codes, + "buyers-csv": cmd_buyers_csv}[d.cmd](d, t) + +if __name__ == "__main__": + main()