设计实践

Gamma 的 pages 数组可以一次生成一个多页文件(最多 50 页),配合 publish 还可以直接发布成网站

Gamma 的 pages 数组可以一次生成一个多页文件(最多 50 页),配合 publish 还可以直接发布成网站。这篇把一个目录里的多个 Markdown 文件做成 pages 请求,并在提交前完成本地校验:

BINBHO · 图文教程

Gamma 的 pages 数组可以一次生成一个多页文件(最多 50 页),配合 publish 还可以直接发布成网站。这篇把一个目录里的多个 Markdown 文件做成 pages 请求,并在提交前完成本地校验:

  • 先用从官方文档页里提取的 OpenAPI Schema 做 JSON Schema 校验,检查类型、枚举和长度;
  • 再补上文档里写了、但 Schema 没有表达的规则:页数 1–50、numCards 不超过 75、path 不能重复,以及 inputTextBreaks 模式下 numCards 不起作用的提醒。

先看清成品(box 上真实运行)

  • 真实抓取了官方 Schema,共 11 个结构。
  • 3 个文件生成 3 页、5 张卡片,校验通过,提交给模拟服务器后状态为 completed。
  • 两个中文文件名最初被生成了相同的 path,已被检查出来,并修正了生成规则。
  • pytest 4 项通过。

合规提醒:调用 Gamma API 需要 Pro、Ultra、Teams、Business 或 Enterprise 计划的 API Key(sk-gamma-…),Key 只放在服务器端。exportUrl 在大约一周内任何拿到链接的人都能下载,要当作密钥来保管。输入的文字和图片必须是你有权使用的;开启网络图片来源时,要注意图片的授权条款,商用场景优先选 webFreeToUseCommercially 或 noImages。在中国境内公开发布 AI 生成的内容,要遵守《生成式人工智能服务管理暂行办法》,并按《人工智能生成合成内容标识办法》加上 AI 标识。Gamma 按额度(credits)计费,本文不列价格。

运行说明:box 上没有 Gamma API Key,所以没有真正生成过文稿。流程跑在我自己写的模拟服务器 fake_gamma.py 上,导出的 PDF 是 Pillow 画的,额度数字也是模拟器编的。用错误的 Key 请求真实接口确实运行过,返回 401 {"message":"Invalid API key","statusCode":401}。

做完之后你会得到什么?

成果 说明
fetch_schema.py 从官方文档页提取 OpenAPI,保存为 gamma_schema.json
build_pages.py 目录 → pages 请求 → 校验 → 写出 request.json
submit.py 提交 request.json(复用 A 篇的客户端)
site/ 3 个示例页面
test_pages.py 测试 Schema 校验和补充规则

需要准备哪些环境?

pip install requests jsonschema pytest;提交时需要的环境和 A 篇相同。

规则来源(官方文档)

  • pages 最多 50 个,每项必须有 inputText,可以单独设置 title、path(不超过 500 字符)、numCards、format 等。提供了 pages 时,顶层的同名字段不再生效。
  • numCards:付费计划可设 1–75。按 Schema 的说明,它只在 cardSplit=auto 时决定卡片数量。
  • 手动分隔卡片:在文本里插入 \n---\n。

文件结构

53-gamma-B-src/
├── fetch_schema.py
├── gamma_schema.json
├── build_pages.py
├── submit.py
├── gamma_deck.py
├── fake_gamma.py
├── site/
│   ├── 01-home.md
│   ├── 02-vendors.md
│   └── 03-faq.md
└── test_pages.py

第 1 步:取官方 Schema

"""从 Gamma 官方文档页(Markdown 版)里取出内嵌的 OpenAPI,保存其中的 components.schemas,供本地校验用。"""
import json, re, sys, requests

URL = "https://developers.gamma.app/generations-classic/create-generation.md"
t = requests.get(URL, timeout=30, headers={"User-Agent": "binbho-tutorial-gamma/1.0"}).text
spec = json.loads(re.search(r'\{"openapi".*', t).group(0).splitlines()[0])
json.dump({"source": URL, "info": spec["info"], "components": spec["components"]},
          open("gamma_schema.json", "w"), ensure_ascii=False, indent=1)
print("已保存 gamma_schema.json:", ", ".join(spec["components"]["schemas"]))
$ python fetch_schema.py
已保存 gamma_schema.json: Generation, PageGeneration, TextOptions, ImageOptions, ImageModel, CardOptions, HeaderFooter, HeaderFooterElement, SharingOptions, EmailOptions, CreateGenerationResponse

第 2 步:构建与校验

"""把一个目录里的多个 Markdown 文件做成一次多页生成请求(pages,1–50 页),提交前在本地校验:
1)用官方 OpenAPI 的 Generation 结构做 JSON Schema 校验(类型、枚举、长度);
2)补充文档里写了但 Schema 没写的规则:pages 1–50 个、付费计划 numCards 1–75、path 不重复,
   以及 cardSplit=inputTextBreaks 时 numCards 不起作用(Schema 说 numCards 只在 auto 时决定卡片数)。"""
import json, pathlib, re, sys
from jsonschema import Draft202012Validator

SCHEMA = json.load(open(pathlib.Path(__file__).with_name("gamma_schema.json"), encoding="utf-8"))
ROOT = {"$ref": "#/components/schemas/Generation", "components": SCHEMA["components"]}


def slug(stem):
    """'02-vendors' → '/vendors';中文文件名转不出英文 slug 时,用编号兜底:'04-交通' → '/page-04'。"""
    num, _, rest = stem.partition("-") if re.match(r"^\d+-", stem) else ("", "", stem)
    s = re.sub(r"[^a-z0-9-]+", "-", rest.lower()).strip("-")
    return "/" + (s or f"page-{num or 'x'}")


def build(folder, export="pdf"):
    pages = []
    for f in sorted(pathlib.Path(folder).glob("*.md")):
        text = f.read_text(encoding="utf-8").strip()
        first = text.splitlines()[0].lstrip("# ").strip()
        pages.append({"inputText": text, "title": first, "path": slug(f.stem),
                      "cardSplit": "inputTextBreaks", "textMode": "preserve", "format": "webpage"})
    return {"pages": pages, "exportAs": export, "textOptions": {"language": "zh-cn"}}


def validate(body):
    errs = [f"{'/'.join(map(str, e.absolute_path)) or '(根)'}: {e.message[:120]}" for e in Draft202012Validator(ROOT).iter_errors(body)]
    pages = body.get("pages", [])
    if not 1 <= len(pages) <= 50:
        errs.append(f"pages: 需要 1–50 页,现在 {len(pages)} 页")
    paths = [p.get("path") for p in pages if p.get("path")]
    for dup in {x for x in paths if paths.count(x) > 1}:
        errs.append(f"pages: path 重复 {dup}")
    warns = []
    for i, p in enumerate(pages):
        n = p.get("numCards")
        if n is not None and n > 75:
            errs.append(f"pages/{i}/numCards: {n} 超过付费计划上限 75")
        if n is not None and p.get("cardSplit") == "inputTextBreaks":
            warns.append(f"pages/{i}: cardSplit=inputTextBreaks 时 numCards 不决定卡片数,实际按 --- 切出 {p['inputText'].count(chr(10)+'---'+chr(10))+1} 张")
    return errs, warns


if __name__ == "__main__":
    body = build(sys.argv[1])
    errs, warns = validate(body)
    print(f"共 {len(body['pages'])} 页:" + ",".join(f"{p['path']}({p['inputText'].count(chr(10)+'---'+chr(10))+1} 张卡)" for p in body["pages"]))
    for w in warns: print("⚠", w)
    for e in errs: print("✗", e)
    if errs: sys.exit(1)
    pathlib.Path("request.json").write_text(json.dumps(body, ensure_ascii=False, indent=1), encoding="utf-8")
    print("✓ 校验通过,已写出 request.json")
"""提交 build_pages.py 写出的 request.json(复用 A 篇 gamma_deck.py 的 create / wait)。"""
import json, sys
import gamma_deck as g

body = json.load(open(sys.argv[1] if len(sys.argv) > 1 else "request.json", encoding="utf-8"))
s = g.session()
gid = g.create(s, body); print("generationId:", gid)
d = g.wait(s, gid, interval=float(sys.argv[2]) if len(sys.argv) > 2 else 5)
print(d["status"], d.get("gammaUrl"), "消耗", d.get("credits", {}).get("deducted"), "导出", g.mask(d.get("exportUrl")))

第 3 步:测试

import json, pathlib
from build_pages import build, validate, slug


def test_build_ok(tmp_path):
    body = build(pathlib.Path(__file__).with_name("site"))
    errs, warns = validate(body)
    assert errs == [] and [p["path"] for p in body["pages"]] == ["/home", "/vendors", "/faq"]


def test_schema_catches_enum_and_length():
    body = {"pages": [{"inputText": "x", "textMode": "rewrite", "title": "t" * 501}], "exportAs": "docx"}
    errs, _ = validate(body)
    txt = " ".join(errs)
    assert "rewrite" in txt and "docx" in txt and "title" in txt


def test_extra_rules():
    body = {"pages": [{"inputText": "a", "path": "/x", "numCards": 80}, {"inputText": "b", "path": "/x"}]}
    errs, _ = validate(body)
    assert any("重复" in e for e in errs) and any("75" in e for e in errs)
    assert validate({"pages": []})[0]
    _, warns = validate({"pages": [{"inputText": "a\n---\nb", "cardSplit": "inputTextBreaks", "numCards": 5}]})
    assert "2 张" in warns[0]


def test_chinese_filename_slug():
    assert slug("04-交通") == "/page-04" and slug("02-vendors") == "/vendors"

第 4 步:运行

$ python build_pages.py site
共 3 页:/home(2 张卡),/vendors(2 张卡),/faq(1 张卡)
✓ 校验通过,已写出 request.json

$ GAMMA_API_BASE=http://127.0.0.1:18531 GAMMA_API_KEY=sk-gamma-demo python submit.py request.json 1
generationId: 9d2dd7ebcf86448d8d1d
completed https://gamma.app/docs/mock-9d2dd7ebcf86448d8d1d 消耗 20 导出 http://127.0.0.1:18531/…(已隐藏)

$ python -m pytest -q
4 passed in 0.04s

调试示例

两个中文文件名生成了相同的 path。 我往目录里加了 04-交通.md 和 05-联系.md。第一版的 slug 规则只保留英文字母和数字,中文全部被去掉,这两页就都退化成了 /page:

共 5 页:/home(2 张卡),/vendors(2 张卡),/faq(1 张卡),/page(1 张卡),/page(1 张卡)
✗ pages: path 重复 /page

好在“path 不重复”这条补充规则把问题拦了下来,没有提交出去。之后修改了 slug 规则,转不出英文时用文件编号兜底,生成 /page-04 和 /page-05,再次运行就通过了。

常见问题

现象 原因 处理
Schema 报枚举错误 字段值写错了(比如 textMode=rewrite) 照报错信息改成枚举中的值
设置了 numCards 但不生效 用的是 inputTextBreaks 模式 改用 --- 控制卡片数,或者改成 auto 模式
校验通过,接口却返回 400 服务端还有本地 Schema 没覆盖到的规则 以接口返回为准,并把这条规则补进 validate()
Schema 过时了 官方更新了接口 重新运行 fetch_schema.py

模板规则

  • 提交前用官方 Schema 校验,再补上文档里的额外规则。
  • path 必须唯一,中文文件名要有兜底规则。
  • Schema 的来源 URL 写进文件里,便于定期更新。

项目资源

用途 地址
Gamma 开发者文档 https://developers.gamma.app/
POST /generations(创建生成) https://developers.gamma.app/generations-classic/create-generation
GET /generations/{id}(轮询状态) https://developers.gamma.app/generations-classic/get-generation-status
参数说明(含 pages) https://developers.gamma.app/guides/generate-api-parameters-explained
jsonschema(Python 库) https://pypi.org/project/jsonschema/

本地校验能挡住大多数会被接口返回 400 的错误,额度只花在通过校验的请求上。