Otter 导出的 TXT 文字稿带有发言人和时间戳。 这篇做
transcript_stats.py:统计每位发言人的时长、占比和最长的一段发言,并用术语表批量修正识别错误(例如 “Kuber Nettie’s” → Kubernetes)。
先看清成品
- 示例文字稿中 2 个术语各修正 2 处,包括小写和大小写混写的变体;不会改动更长单词中的部分(Postgresql 不变)。
- 发现并修复真实问题:文字稿从 01:05 跳到 59:50,第一版把 58 分钟都算给了 Alex(59:12,占 98.3%)。
- 中英文混合计数:汉字按字,英文按词。pytest 5 项通过。
合规提醒:录音和转写前要告知所有参会者并取得同意(不同地区对录音同意的法律要求不同);分享文字稿前确认里面没有不该外传的信息。本文只使用 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)。
做完之后你会得到什么?
| 成果 | 说明 |
|---|---|
transcript_stats.py |
统计和术语修正 |
meeting.txt |
示例文字稿(我写的) |
glossary.csv |
术语表 |
test_stats.py |
5 项测试 |
需要准备哪些环境?
| 项目 | 说明 |
|---|---|
| Python | 3.9 以上,pip install requests pytest |
| Otter 账号 | A 篇需要 Enterprise 工作区和 API key;B、C 篇用任意套餐的导出文件即可(Basic 只能导出 TXT) |
| 系统 | Windows / macOS / Linux 均可 |
关键事实
| 事实 | 来源 |
|---|---|
| 导出:打开对话 → 更多 → Export;Basic 套餐只能导出 TXT,DOCX/PDF/SRT 需要付费套餐 | 官方导出帮助文章 |
| 导出时可以选择是否包含发言人名字和时间戳 | 同上 |
| 只有协作者,或被允许导出的查看者,才能导出 | 同上 |
API 返回的文字稿格式是 发言人 00:00 换行接正文 |
官方 Public API 帮助文章中的示例 |
导出时请勾选发言人名字和时间戳,否则脚本无法统计。
文件结构
70-otter-ai-B-src/
├── transcript_stats.py
├── meeting.txt
├── glossary.csv
└── test_stats.py
第 1 步:示例文字稿
Jane Doe 00:00
Thanks everyone for joining. Today we will review the migration to Kuber Nettie's and the Post gress upgrade.
Alex Taylor 00:12
The staging cluster is ready. We moved three services last week and the Post Gress replica is in sync.
Jane Doe 00:31
Great. What is blocking the last two services?
王明 00:38
主要是配置中心还没有迁移,预计下周三完成。另外 kuber nettie's 的监控告警需要重新配置。
Alex Taylor 01:05
I can help with the alerts. Let's pair on Thursday.
Jane Doe 59:50
Before we close, any other topics?
Alex Taylor 1:00:02
None from me.
Jane Doe 1:00:10
Thanks, see you next week.
第 2 步:术语表
# 识别错误,正确写法
Kuber Nettie's,Kubernetes
Post gress,Postgres
第 3 步:脚本
"""分析 Otter 的文字稿:每个发言人的发言时长、字数、最长的一段发言;并按术语表批量修正识别错误。
输入格式:Otter 导出 TXT 时勾选“显示发言人”和“显示时间戳”,或 Public API 返回的 transcript.content。
两者都是“发言人 时间戳”一行,下面是发言内容,段与段之间空一行(见官方 API 示例)。"""
import argparse, csv, re, sys
from collections import defaultdict
HEAD = re.compile(r"^(?P<name>\S.*?)\s{2,}(?P<ts>\d{1,2}:\d{2}(?::\d{2})?)\s*$")
def ts_sec(ts):
p = [int(x) for x in ts.split(":")]
return p[0] * 60 + p[1] if len(p) == 2 else p[0] * 3600 + p[1] * 60 + p[2]
def parse(text):
blocks, cur = [], None
for line in text.splitlines():
m = HEAD.match(line)
if m:
cur = {"speaker": m["name"].strip(), "start": ts_sec(m["ts"]), "text": ""}; blocks.append(cur)
elif cur is not None and line.strip():
cur["text"] = (cur["text"] + " " + line.strip()).strip()
return blocks
def nwords(t):
"""英文按空格计词;中文每个汉字算一个字。"""
return len(re.findall(r"[\u4e00-\u9fff]", t)) + len(re.findall(r"[A-Za-z0-9']+", t))
def stats(blocks, end=None, wpm=150):
out = defaultdict(lambda: {"seconds": 0, "words": 0, "turns": 0, "longest": 0})
for i, b in enumerate(blocks):
nxt = blocks[i + 1]["start"] if i + 1 < len(blocks) else (end if end else b["start"] + round(nwords(b["text"]) / wpm * 60))
d = max(nxt - b["start"], 0); s = out[b["speaker"]]
# 中间有长时间停顿(休息、静音、文字稿被截取)时,不能把整段空白都算给上一位发言人:
# 以字数估算的时长 ×3(至少 30 秒)为上限,多出的部分记为“空白”
cap = max(round(nwords(b["text"]) / wpm * 60 * 3), 30)
if d > cap: out["(空白/停顿)"]["seconds"] += d - cap; d = cap
s["seconds"] += d; s["words"] += nwords(b["text"]); s["turns"] += 1; s["longest"] = max(s["longest"], d)
total = sum(v["seconds"] for k, v in out.items() if not k.startswith("(")) or 1
return {k: dict(v, share=0.0 if k.startswith("(") else round(v["seconds"] / total * 100, 1)) for k, v in sorted(out.items(), key=lambda kv: -kv[1]["seconds"])}
def load_glossary(path):
return [(r[0], r[1]) for r in csv.reader(open(path, encoding="utf-8")) if r and not r[0].startswith("#")]
def fix_terms(text, glossary):
counts = {}
for wrong, right in sorted(glossary, key=lambda g: -len(g[0])): # 先替换长词组
pat = re.compile(r"(?<![\w])" + re.escape(wrong) + r"(?![\w])", re.I)
text, n = pat.subn(right, text)
if n: counts[f"{wrong} → {right}"] = n
return text, counts
def fmt(sec): return f"{sec // 60}:{sec % 60:02d}"
def main(argv=None):
ap = argparse.ArgumentParser(); ap.add_argument("transcript"); ap.add_argument("--glossary"); ap.add_argument("--fixed")
ap.add_argument("--end", help="会议结束时间(mm:ss 或 h:mm:ss),用于计算最后一段的时长"); a = ap.parse_args(argv)
text = open(a.transcript, encoding="utf-8").read()
if a.glossary:
text, counts = fix_terms(text, load_glossary(a.glossary))
for k, n in counts.items(): print(f"修正 {k}:{n} 处")
if a.fixed: open(a.fixed, "w", encoding="utf-8").write(text); print("写入", a.fixed)
blocks = parse(text)
if not blocks: raise SystemExit("没有识别到“发言人 时间戳”格式的段落:导出时请勾选显示发言人和时间戳")
print(f"{'发言人':<14}{'时长':>8}{'占比':>8}{'字/词':>8}{'次数':>6}{'最长一段':>10}")
for k, v in stats(blocks, ts_sec(a.end) if a.end else None).items():
print(f"{k:<14}{fmt(v['seconds']):>8}{v['share']:>7}%{v['words']:>8}{v['turns']:>6}{fmt(v['longest']):>10}")
return 0
if __name__ == "__main__":
sys.exit(main())
第 4 步:测试
import transcript_stats as T
TXT = open("meeting.txt", encoding="utf-8").read()
def test_parse_and_timestamps():
b = T.parse(TXT)
assert [x["speaker"] for x in b][:4] == ["Jane Doe", "Alex Taylor", "Jane Doe", "王明"]
assert T.ts_sec("1:00:02") == 3602 and T.ts_sec("59:50") == 3590 and b[-1]["start"] == 3610
def test_official_api_example():
# 官方 API 示例中 transcript.content 的格式
ex = "Jane Doe 00:00\nThanks everyone for joining today.\n\nAlex Taylor 00:07\nRight now we take notes manually."
s = T.stats(T.parse(ex), end=12)
assert s["Jane Doe"]["seconds"] == 7 and s["Alex Taylor"]["seconds"] == 5
def test_gap_not_given_to_speaker():
# 调试时发现:01:05 → 59:50 的跳跃让 Alex 的发言时长变成 59:12(98%)
s = T.stats(T.parse(TXT), end=3615)
assert s["Alex Taylor"]["seconds"] < 120 and s["(空白/停顿)"]["seconds"] > 3000
def test_glossary_whole_word_case_insensitive():
fixed, c = T.fix_terms(TXT, [("Kuber Nettie's", "Kubernetes"), ("Post gress", "Postgres")])
assert c == {"Kuber Nettie's → Kubernetes": 2, "Post gress → Postgres": 2}
assert "kuber nettie" not in fixed.lower()
assert T.fix_terms("Postgresql", [("Postgres", "PG")])[1] == {} # 不替换更长单词的一部分
def test_chinese_word_count():
assert T.nwords("主要是配置中心 API 迁移") == 10 # 9 个汉字 + 1 个英文词
第 5 步:运行
$ python transcript_stats.py meeting.txt --glossary glossary.csv --fixed meeting_fixed.txt --end 1:00:15
修正 Kuber Nettie's → Kubernetes:2 处
修正 Post gress → Postgres:2 处
写入 meeting_fixed.txt
发言人 时长 占比 字/词 次数 最长一段
(空白/停顿) 58:15 0.0% 0 0 0:00
Alex Taylor 0:57 47.5% 31 3 0:30
Jane Doe 0:36 30.0% 35 4 0:12
王明 0:27 22.5% 33 1 0:27
$ python -m pytest -q
5 passed
怎么检查结果?
- 各发言人占比之和为 100%,空白不计入占比。
- 修正后的文件中搜索不到术语表左边的错误写法。
- 时长是根据时间戳之差估算的,不是精确的发言时长。
调试示例
中间的空白被算给了上一位发言人。 第一版用“下一段的开始时间 − 本段开始时间”作为本段时长。示例文字稿中 Alex 在 01:05 说完后,下一段从 59:50 开始(我故意截掉了中间部分,真实会议中的休息、长时间静音也会这样)。结果:
Alex Taylor 59:12 98.3% ... 最长一段 58:45
一段 15 个词的发言不可能持续 58 分钟。修复:按字数估算发言时长(每分钟 150 词),以估算值的 3 倍(至少 30 秒)为上限,超出部分记入“(空白/停顿)”,不计入占比。修复后 Alex 是 0:57(47.5%)。
测试中我自己算错了字数。 “主要是配置中心 API 迁移”我先写成 9,实际是 9 个汉字 + 1 个英文词 = 10,脚本是对的。
常见问题
| 现象 | 原因 | 处理 |
|---|---|---|
| 统计结果为空 | 导出时没有勾选发言人或时间戳 | 重新导出 |
| 某人占比异常高 | 中间有长时间空白 | 看“(空白/停顿)”一行;必要时调整上限 |
| 中文发言时长偏短 | 每分钟 150 字的估算对中文偏低 | 只影响上限,不影响正常时长 |
| 术语改错了别的词 | 术语太短 | 术语表只写完整的错误写法 |
模板规则
- 导出时包含发言人和时间戳。
- 术语表只写完整短语。
- 统计结果只作参考,不用于评价个人表现。
项目资源
| 用途 | 地址 |
|---|---|
| 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/ |
先把术语修正好,再看谁说了多少。