#!/usr/bin/env python3 """AccesPub API quickstart (Python 3.9+, `pip install requests`). One EPUB from start to finish: create a batch, upload, wait for the analysis, list what needs human review, review it (interactively with --review), finalise and download the accessible EPUB, its report and the accessibility ONIX. export ACCESPUB_KEY=ap_test_... # use a test key first: it uses no credits export ACCESPUB_REVIEWER=laura@example.com # a registered person in your organisation export ACCESPUB_REVIEWER_NAME="Laura Pons" python quickstart.py my-book.epub # finalise without review (no conformance) python quickstart.py my-book.epub --review # review each item in the terminal Docs: https://api.accespub.app/docs """ import argparse import os import sys import time import uuid import requests API = os.environ.get("ACCESPUB_API", "https://api.accespub.app/v1").rstrip("/") KEY = os.environ.get("ACCESPUB_KEY", "") REVIEWER = os.environ.get("ACCESPUB_REVIEWER", "") REVIEWER_NAME = os.environ.get("ACCESPUB_REVIEWER_NAME", "") s = requests.Session() s.headers["Authorization"] = f"Bearer {KEY}" def call(method, path, *, idempotent=False, **kw): """Calls the API. Errors are RFC 9457 problem documents: print them and stop.""" headers = kw.pop("headers", {}) if idempotent: # safe to retry: the same key returns the original response headers["Idempotency-Key"] = str(uuid.uuid4()) r = s.request(method, API + path, headers=headers, timeout=120, **kw) if r.status_code == 429: # rate limited: wait and retry once time.sleep(int(r.headers.get("Retry-After", "5"))) r = s.request(method, API + path, headers=headers, timeout=120, **kw) if r.status_code >= 400: p = r.json() if r.headers.get("content-type", "").startswith("application/problem+json") else {} errs = "; ".join(f"{e.get('pointer', '')} {e.get('detail', '')}" for e in p.get("errors") or []) sys.exit(f"{method} {path} → {r.status_code} {p.get('title', '')}: {p.get('detail', r.text[:300])} {errs}") return r def review_interactively(job_id): """Shows each pending item with its context and asks for a decision.""" items, cursor = [], None while True: # lists are paginated: up to 100 items per page, follow next_cursor page = call("GET", f"/jobs/{job_id}/review-items", params={"status": "pending", "limit": 100, **({"cursor": cursor} if cursor else {})}).json() items += page["data"] cursor = page.get("next_cursor") if not cursor: break decisions = [] for it in items: print(f"\n[{it['kind']}] {it['id']} — {it['document']}") print(f" …{it['context_before'][-160:]} ▢ {it['context_after'][:160]}…") if it["kind"] == "image": sug = call("POST", f"/jobs/{job_id}/review-items/{it['id']}/suggestion").json()["text"] print(f" AI suggestion (not applied unless you accept it): {sug}") alt = input(" Alt text (Enter = accept suggestion, '-' = decorative, 's' = skip): ").strip() if alt == "s": continue if alt == "-": d = {"kind": "image", "decorative": True} else: d = {"kind": "image", "decorative": False, "alt": alt or sug, "from_suggestion": not alt} elif it["kind"] == "table": ans = input(" Data table (d) or layout table (l)? [d/l/s] ").strip().lower() if ans not in ("d", "l"): continue d = {"kind": "table", "layout": ans == "l", "caption": "", "first_row_as_headers": ans == "d"} else: # language: keep what AccesPub found (see the guide for replace/remove) lang = it["language"] print(f" Language ({lang['scope']}): {lang.get('lang_value')} detected: {lang.get('detected', '')}") if input(" Keep it? [Y/n] ").strip().lower() == "n": continue d = {"kind": "language", "action": "keep"} decisions.append({"item_id": it["id"], "decision": d}) for i in range(0, len(decisions), 200): # up to 200 decisions per request res = call("POST", f"/jobs/{job_id}/decisions", idempotent=True, json={"reviewer": {"name": REVIEWER_NAME or REVIEWER, "email": REVIEWER}, "decisions": decisions[i:i + 200]}).json() print(f"\nSaved {res['saved']} decisions · pending: {res['review']['pending']}") def main(): ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("epub") ap.add_argument("--review", action="store_true", help="review the pending items in the terminal") ap.add_argument("--out", default=".", help="folder for the downloads") a = ap.parse_args() if not KEY: sys.exit("Set ACCESPUB_KEY (ap_test_… or ap_live_…).") if a.review and not REVIEWER: sys.exit("--review needs ACCESPUB_REVIEWER (the email of a registered person).") org = call("GET", "/organization").json() print(f"{org['name']} · mode {org['mode']} · credits available {org['credits']['available']}") batch = call("POST", "/batches", idempotent=True, json={"name": f"Quickstart {time.strftime('%Y-%m-%d %H:%M')}"}).json() with open(a.epub, "rb") as f: job = call("POST", f"/batches/{batch['id']}/jobs", idempotent=True, files={"file": (os.path.basename(a.epub), f, "application/epub+zip")}).json() print(f"Uploaded {job['filename']} → job {job['id']} (batch {batch['id']})") while job["status"] in ("queued", "processing"): # or receive a webhook (job.needs_review, job.completed) time.sleep(10) job = call("GET", f"/jobs/{job['id']}").json() print(f" status: {job['status']}") if job["status"] == "failed": sys.exit(f"Processing failed: {job['error']}") b = job["billing"] print(f"{b['images']} images → {b['credits_required']} credit(s) on finalisation " f"(1 per {b['images_per_credit']} images); review: {job['review']['pending']} pending of {job['review']['total']}") if a.review and job["review"]["pending"]: review_interactively(job["id"]) job = call("GET", f"/jobs/{job['id']}").json() pending = job["review"]["pending"] if pending: print(f"{pending} items still pending: finalising WITHOUT declaring conformance (allow_pending).") call("POST", f"/jobs/{job['id']}/finalize", idempotent=True, json={"allow_pending": bool(pending)}) while job["status"] != "completed": time.sleep(5) job = call("GET", f"/jobs/{job['id']}").json() if job["status"] in ("failed", "ready", "needs_review") and job.get("error"): sys.exit(f"Finalisation failed: {job['error']}") print(f"Completed · conformance: {job['conformance']}") os.makedirs(a.out, exist_ok=True) stem = os.path.splitext(os.path.basename(a.epub))[0] for kind, name in (("epub", f"{stem}-accessible.epub"), ("report_html", f"{stem}-report.html"), ("onix_a11y", f"{stem}-onix.xml")): r = call("GET", f"/jobs/{job['id']}/files/{kind}") with open(os.path.join(a.out, name), "wb") as f: f.write(r.content) print(f" saved {name}") if __name__ == "__main__": main()