效率工具

用 Otter Public API 导出近期会议纪要:分页、限速与错误处理

Otter 的 Public API 可以列出工作区里的会议,并取回摘要、大纲和待办事项。 这篇做 otterminutes.py:把最近 N 天的会议各生成一份 Markdown 纪要(时间换算成 UTC+8),遵守官方限速,正确处理 429 和 401。

BINBHO · 图文教程

Otter 的 Public API 可以列出工作区里的会议,并取回摘要、大纲和待办事项。 这篇做 otter_minutes.py:把最近 N 天的会议各生成一份 Markdown 纪要(时间换算成 UTC+8),遵守官方限速,正确处理 429 和 401。

先看清成品

  • 用无效 key 真实调用官方接口,得到 401 {"error": "unauthorized"},脚本给出可读的提示,退出码 1。
  • 本地替身上完整跑通:第一次请求 429 → 退避后成功,分页在超出时间范围时停止,生成 2 份纪要。
  • 修正了一个误报:官方示例中 process_status 为 null 但数据已存在。pytest 6 项通过。

合规提醒:录音和转写前要告知所有参会者并取得同意(不同地区对录音同意的法律要求不同);分享文字稿前确认里面没有不该外传的信息。本文只使用 Otter 官方功能:网页/客户端的导出,以及官方 Public API(仅 Enterprise 工作区)。不使用非官方接口或抓包,不共享账号。功能和套餐以 Otter 官方说明为准(本文不列价格)。

运行说明:box 上没有 Otter 账号,也没有 Enterprise API key。我只用一个无效 key 真实调用过 GET https://api.otter.ai/v1/conversations,得到 HTTP 401 {"error": "unauthorized"}(2026-10-21 19:58,UTC+8)。其余接口响应来自我自己写的本地替身(字段结构按官方帮助文章中的示例)。示例文字稿、姓名、联系方式都是虚构的。所有脚本和测试都在 box 上真实运行过(Python 3.13,requests)。

做完之后你会得到什么?

成果 说明
otter_minutes.py 调用 API 生成纪要
fake_otter.py 本地替身(我写的,字段按官方示例)
demo_local.py 用替身演示一次完整运行
test_minutes.py 6 项测试

需要准备哪些环境?

项目 说明
Python 3.9 以上,pip install requests pytest
Otter 账号 A 篇需要 Enterprise 工作区和 API key;B、C 篇用任意套餐的导出文件即可(Basic 只能导出 TXT)
系统 Windows / macOS / Linux 均可

关键事实

事实 来源
Public API 仅对 Enterprise 工作区开放;在 Integrations → Developer 中创建 API key,每人最多 2 个 官方 Public API 帮助文章
认证方式:Authorization: Bearer <key>;限速每秒 10 个请求,超出返回 429 同上
列表接口 GET /v1/conversations:limit 1–100,用 meta.has_more 和 next_cursor 翻页 同上
详情接口 GET /v1/conversations/{id}?include=action_items,insights,outline,transcript;官方建议不要使用 include=all 同上
官方推荐用 Webhook 接收新会议,而不是频繁轮询 同上

help.otter.ai 对 curl 返回 403(站点限制),用浏览器可以正常打开。接口字段如有变化,以官方文章为准。

文件结构

70-otter-ai-A-src/
├── otter_minutes.py
├── fake_otter.py
├── demo_local.py
└── test_minutes.py

第 1 步:创建 API key(手动)

Otter 网页版 → Integrations → Developer → 创建 key。把 key 放进环境变量 OTTER_API_KEY,不要写进代码或提交到仓库。(本文没有 Enterprise 账号,没有实际操作这一步。)

第 2 步:主脚本

"""用 Otter.ai Public API(Enterprise 工作区)把最近的会议整理成 Markdown 纪要:摘要、大纲、待办事项。
- 鉴权:Authorization: Bearer <API key>(Integrations → Developer → Create key;每个用户最多 2 个 key)
- GET /v1/conversations:按时间倒序、游标分页,limit 1~100
- GET /v1/conversations/{id}?include=action_items,outline:官方建议只请求需要的字段,不要用 all
- 速率限制:每秒 10 次,超过返回 429;按官方建议使用指数退避
只读取数据,不修改 Otter 中的任何内容。"""
import argparse, datetime as dt, os, re, sys, time
import requests

API = os.environ.get("OTTER_API", "https://api.otter.ai/v1")

class OtterError(Exception): pass

class Client:
    def __init__(self, key=None, sleep=time.sleep, min_interval=0.11, max_retry=5):
        self.s = requests.Session(); self.s.headers.update({"Authorization": f"Bearer {key or os.environ['OTTER_API_KEY']}", "User-Agent": "binbho-tutorial/1.0"})
        self.sleep, self.min_interval, self.max_retry, self.last = sleep, min_interval, max_retry, 0.0
        self.api = os.environ.get("OTTER_API", API)

    def get(self, path, **params):
        for attempt in range(self.max_retry + 1):
            wait = self.min_interval - (time.monotonic() - self.last)      # 主动限速,保持在每秒 10 次以内
            if wait > 0: self.sleep(wait)
            self.last = time.monotonic()
            r = self.s.get(self.api + path, params=params, timeout=30)
            if r.status_code == 429 and attempt < self.max_retry:
                self.sleep(min(2 ** attempt, 30)); continue
            if r.status_code == 401: raise OtterError("401 unauthorized:API key 无效,或工作区不是 Enterprise")
            if r.status_code >= 400: raise OtterError(f"HTTP {r.status_code}: {r.text[:200]}")
            return r.json()
        raise OtterError("连续收到 429,已放弃")

    def conversations(self, since=None, limit=50):
        """倒序遍历;遇到早于 since 的会议就停止翻页。"""
        cursor = None
        while True:
            j = self.get("/conversations", limit=limit, **({"cursor": cursor} if cursor else {}))
            for c in j["data"]:
                if since and parse_ts(c["created_at"]) < since: return
                yield c
            meta = j.get("meta", {})
            if not meta.get("has_more"): return
            cursor = meta["next_cursor"]

def parse_ts(s): return dt.datetime.fromisoformat(s.replace("Z", "+00:00").replace(" ", "T"))

def to_local(s): return parse_ts(s).astimezone(dt.timezone(dt.timedelta(hours=8))).strftime("%Y-%m-%d %H:%M")

def minutes_md(conv):
    rel = conv.get("relationships") or {}
    out = [f"# {conv['title']}", "", f"- 时间:{to_local(conv['created_at'])}(UTC+8)", f"- 负责人:{conv['owner']['name']}", f"- Otter 链接:{conv['url']}", ""]
    ps = conv.get("process_status") or {}
    # 官方示例中,process_status 全为 null 时摘要和待办仍然存在,所以 null 不等于“没处理完”:
    # 只有状态明确不是 finished,或者为 null 且对应的数据确实缺失时,才提示
    have = {"abstract_summary": conv.get("abstract_summary"), "action_item": rel.get("action_items"), "outline": rel.get("outline")}
    pending = [k for k, v in ps.items() if (v is not None and v != "finished") or (v is None and not have.get(k))]
    if pending:
        out += [f"> 注意:以下内容 Otter 可能还没有生成:{', '.join(pending)}", ""]
    out += ["## 摘要", "", conv.get("abstract_summary") or "(无)", ""]
    if rel.get("outline"):
        out += ["## 大纲", ""] + [f"### {s['section']}\n" + "\n".join(f"- {t}" for t in s["text"]) + "\n" for s in rel["outline"]]
    out += ["## 待办事项", ""]
    items = rel.get("action_items") or []
    for a in items:
        done = (a.get("status") or {}).get("completed")
        who = (a.get("assignee") or {}).get("name") or "未指定"
        out.append(f"- [{'x' if done else ' '}] {a['text']}({who})")
    if not items: out.append("(无)")
    return "\n".join(out) + "\n"

def slug(s): return re.sub(r"[^\w\u4e00-\u9fff-]+", "-", s).strip("-")[:60] or "meeting"

def main(argv=None, client=None):
    ap = argparse.ArgumentParser(); ap.add_argument("--days", type=int, default=7); ap.add_argument("--out", default="minutes")
    a = ap.parse_args(argv); c = client or Client()
    since = dt.datetime.now(dt.timezone.utc) - dt.timedelta(days=a.days); os.makedirs(a.out, exist_ok=True); n = 0
    for conv in c.conversations(since):
        full = c.get(f"/conversations/{conv['id']}", include="action_items,outline")["data"]
        path = os.path.join(a.out, f"{parse_ts(full['created_at']).astimezone(dt.timezone(dt.timedelta(hours=8))):%Y%m%d}-{slug(full['title'])}.md")
        open(path, "w", encoding="utf-8").write(minutes_md(full)); n += 1; print("写入", path)
    print(f"共 {n} 场会议(最近 {a.days} 天)")
    return 0

if __name__ == "__main__":
    try: sys.exit(main())
    except OtterError as e: print("Otter API 错误:", e); sys.exit(1)

第 3 步:本地替身

"""本地替身(我自己写的,不是 Otter 官方的)。字段结构照抄官方 Public API 帮助文章中的示例;401 的响应体是对真实接口的实测结果。"""
import json, threading, datetime as dt
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlparse, parse_qs

OWNER = {"id": "owner_id", "name": "Jane Doe", "first_name": "Jane", "last_name": "Doe", "email": "jane.doe@example.com"}

def conv(i, days_ago, title, finished=True):
    t = (dt.datetime.now(dt.timezone.utc) - dt.timedelta(days=days_ago)).strftime("%Y-%m-%dT%H:%M:%SZ")
    st = "finished" if finished else None
    return {"id": f"c{i}", "title": title, "url": f"https://otter.ai/u/c{i}", "owner": OWNER, "created_at": t,
            "process_status": {"abstract_summary": st, "action_item": st, "outline": st}, "calendar_guests": None,
            "shared_emails": [], "shared_channels": [], "abstract_summary": f"{title} 的摘要" if finished else None, "conf_join_url": None}

class State:
    def __init__(self):
        self.convs = [conv(1, 1, "Customer Discovery Call – ACME Corp"), conv(2, 3, "周会 / 产品规划", finished=False), conv(3, 20, "旧会议")]
        self.fail_429 = 0; self.calls = []; self.times = []

def make(st):
    class H(BaseHTTPRequestHandler):
        def log_message(self, *a): pass
        def send(self, code, obj):
            b = json.dumps(obj).encode(); self.send_response(code); self.send_header("Content-Type", "application/json")
            self.send_header("Content-Length", str(len(b))); self.end_headers(); self.wfile.write(b)
        def do_GET(self):
            import time
            u = urlparse(self.path); q = parse_qs(u.query); st.calls.append((u.path, q)); st.times.append(time.monotonic())
            if self.headers.get("Authorization") != "Bearer good": return self.send(401, {"error": "unauthorized"})
            if st.fail_429 > 0: st.fail_429 -= 1; return self.send(429, {"error": "Too Many Requests"})
            meta = {"retrieved_at": "2026-10-21T12:00:00Z"}
            if u.path == "/v1/conversations":
                lim = int(q.get("limit", ["20"])[0])
                if not 1 <= lim <= 100: return self.send(400, {"error": "invalid limit"})
                start = int(q.get("cursor", ["0"])[0]); page = st.convs[start:start + lim]; more = start + lim < len(st.convs)
                meta.update(has_more=more, **({"next_cursor": str(start + lim)} if more else {}))
                return self.send(200, {"meta": meta, "data": page})
            if u.path.startswith("/v1/conversations/"):
                cid = u.path.rsplit("/", 1)[1]; c = next((x for x in st.convs if x["id"] == cid), None)
                if not c: return self.send(404, {"error": "Conversation not found"})
                inc = set(q.get("include", [""])[0].split(","))
                rel = {"action_items": None, "insights": None, "outline": None, "transcript": None, "custom_prompt": None}
                if "action_items" in inc:
                    rel["action_items"] = [{"id": "a1", "text": "Send follow-up email with pricing summary and implementation overview.", "assignee": OWNER, "status": None},
                                           {"id": "a2", "text": "ACME to confirm final user count and preferred contract term.",
                                            "assignee": {"id": "x", "name": "Alex Taylor", "first_name": "Alex", "last_name": "Taylor", "email": "alex.taylor@example.com"},
                                            "status": {"completed": True, "created_at": "2025-07-31T12:00:00Z", "last_modified_at": "2025-07-31T12:00:00Z", "completed_at": "2025-07-31T12:00:00Z"}}]
                if "outline" in inc:
                    rel["outline"] = [{"section": "Introductions and Context", "text": ["Participants introduced themselves and clarified roles."]},
                                      {"section": "Pricing and Rollout Options", "text": ["The team reviewed per-seat and flat-rate pricing options."]}]
                return self.send(200, {"meta": meta, "data": dict(c, relationships=rel)})
            self.send(404, {"error": "not found"})
    srv = ThreadingHTTPServer(("127.0.0.1", 0), H); threading.Thread(target=srv.serve_forever, daemon=True).start()
    return srv, f"http://127.0.0.1:{srv.server_address[1]}/v1"

第 4 步:演示脚本

"""用本地替身演示一次完整运行(不连接 Otter)。用法:python demo_local.py [key]"""
import os, sys, fake_otter as F
st = F.State(); st.fail_429 = 1                      # 第一次请求返回 429,演示退避
srv, url = F.make(st)
os.environ["OTTER_API"] = url; os.environ["OTTER_API_KEY"] = sys.argv[1] if len(sys.argv) > 1 else "good"
import otter_minutes as M
try: code = M.main(["--days", "7", "--out", "minutes"])
except M.OtterError as e: print("Otter API 错误:", e); code = 1
finally: srv.shutdown()
sys.exit(code)

第 5 步:测试

import os, pytest
import fake_otter as F, otter_minutes as O

@pytest.fixture
def env(monkeypatch):
    st = F.State(); srv, url = F.make(st); monkeypatch.setenv("OTTER_API", url); yield st; srv.shutdown()

def test_minutes(env, tmp_path):
    out = tmp_path / "m"
    O.main(["--days", "7", "--out", str(out)], client=O.Client("good", min_interval=0))
    files = sorted(os.listdir(out)); assert len(files) == 2                  # 20 天前的会议不导出
    acme = (out / [f for f in files if "ACME" in f][0]).read_text(encoding="utf-8")
    assert "- [ ] Send follow-up email" in acme and "- [x] ACME to confirm" in acme and "### Pricing and Rollout Options" in acme
    weekly = (out / [f for f in files if "ACME" not in f][0]).read_text(encoding="utf-8")
    assert "可能还没有生成" in weekly
    details = [c for c in env.calls if c[0].startswith("/v1/conversations/")]
    assert all(c[1]["include"] == ["action_items,outline"] for c in details)    # 不用 include=all

def test_pagination_stops_at_since(env):
    c = O.Client("good", min_interval=0)
    import datetime as dt
    got = [x["id"] for x in c.conversations(dt.datetime.now(dt.timezone.utc) - dt.timedelta(days=7), limit=1)]
    assert got == ["c1", "c2"] and len([x for x in env.calls if x[0] == "/v1/conversations"]) == 3

def test_401(env):
    with pytest.raises(O.OtterError, match="401"): list(O.Client("bad", min_interval=0).conversations())

def test_429_backoff(env):
    waits = []; env.fail_429 = 2
    c = O.Client("good", sleep=waits.append, min_interval=0); c.get("/conversations", limit=5)
    assert waits == [1, 2]

def test_rate_limit_interval(env):
    c = O.Client("good")                                                         # 默认最小间隔 0.11 秒
    for _ in range(5): c.get("/conversations", limit=5)
    gaps = [b - a for a, b in zip(env.times, env.times[1:])]
    assert min(gaps) >= 0.1

def test_null_status_with_data_is_not_pending():
    # 调试时发现:官方示例的 process_status 全是 null,但摘要和待办都在,第一版仍然提示“还没有处理完”
    ex = {"relationships": {"action_items": [{"id": "a", "text": "Follow up with the sales team", "assignee": {"name": "Jane Doe"}, "status": {"completed": True}}],
                            "outline": None}, "id": "conversation_id", "title": "product launch meeting", "url": "https://otter.ai/u/conversation_id",
          "owner": {"name": "Jane Doe"}, "created_at": "2025-07-31T12:00:00Z",
          "process_status": {"abstract_summary": None, "action_item": None, "outline": None}, "abstract_summary": "This meeting discussed the next steps"}
    md = O.minutes_md(ex)
    assert "可能还没有生成:outline" in md and "abstract_summary" not in md.split("可能还没有生成")[1]

第 6 步:运行

$ python demo_local.py          # 本地替身,第一次请求故意返回 429
写入 minutes/20261020-Customer-Discovery-Call-ACME-Corp.md
写入 minutes/20261018-周会-产品规划.md
共 2 场会议(最近 7 天)
$ cat minutes/20261018-周会-产品规划.md
# 周会 / 产品规划

- 时间:2026-10-18 20:02(UTC+8)
- 负责人:Jane Doe
- Otter 链接:https://otter.ai/u/c2

> 注意:以下内容 Otter 可能还没有生成:abstract_summary

## 摘要

(无)

## 大纲

### Introductions and Context
- Participants introduced themselves and clarified roles.

### Pricing and Rollout Options
- The team reviewed per-seat and flat-rate pricing options.

## 待办事项

- [ ] Send follow-up email with pricing summary and implementation overview.(Jane Doe)
- [x] ACME to confirm final user count and preferred contract term.(Alex Taylor)

$ python demo_local.py bad
Otter API 错误: 401 unauthorized:API key 无效,或工作区不是 Enterprise
$ curl -s -w ' %{http_code}' -H 'Authorization: Bearer invalid' https://api.otter.ai/v1/conversations     # 真实接口
{"error": "unauthorized"} 401
$ python -m pytest -q
6 passed

怎么检查结果?

  • 两次请求之间至少间隔 0.11 秒(每秒不超过 10 次);测试会检查请求时间戳。
  • 429 时按 1、2、4 秒退避重试。
  • 纪要中的时间是 UTC+8;API 返回的是 UTC(以 Z 结尾)。
  • 摘要等字段还没生成时,纪要顶部会给出提示。

调试示例

process_status 为 null 时误报“还没生成”。 第一版只要 process_status 中某一项不是 finished 就给出警告。用官方帮助文章中的示例响应测试时,各项状态都是 null,但摘要和待办事项其实已经存在,结果每份纪要都多了一条错误的提示。 修复:只有状态明确是未完成,或者状态为 null 且对应数据确实为空时,才提示。替身中“周会”的摘要为空,所以仍然提示 abstract_summary,这是正确的。

401。 用无效 key 调用真实接口得到 401。官方文章说明 API 只对 Enterprise 开放,所以提示同时写出“key 无效”和“工作区不是 Enterprise”两种可能。

常见问题

现象 原因 处理
401 unauthorized key 错误,或工作区不是 Enterprise 检查 key;确认套餐
429 超出每秒 10 次的限速 脚本会自动退避;不要并发运行多份
纪要缺少摘要 Otter 还在处理 稍后再运行,或改用 Webhook
会议太多、太慢 include 了 transcript 只取需要的字段;不要用 include=all

模板规则

  • API key 只放在环境变量里。
  • 遵守限速,处理 429。
  • 只请求需要的字段。
  • 生成的纪要和原会议一样,只分享给有权限的人。

项目资源

用途 地址
Otter Public API 帮助文章(官方) https://help.otter.ai/hc/en-us/articles/36130822688279
Otter 导出对话帮助文章(官方) https://help.otter.ai/hc/en-us/articles/360047733634
Otter 官网 https://otter.ai/

让 API 搬运数据,纪要的内容仍要由参会者确认。