gumroad-cli: Gumroad API v2 CLI (products/sales/followers/offer-codes/buyers-csv)

This commit is contained in:
WMJ
2026-08-12 02:24:47 +02:00
commit 8bc2218079
2 changed files with 147 additions and 0 deletions
+124
View File
@@ -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 <id> 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()