效率工具

Otter 转写稿分析:统计发言人时长并批量修正术语

Otter 导出的 TXT 文字稿带有发言人和时间戳。 这篇做 transcriptstats.py:统计每位发言人的时长、占比和最长的一段发言,并用术语表批量修正识别错误(例如 “Kuber Nettie’s” → Kubernetes)。

BINBHO · 图文教程

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/

先把术语修正好,再看谁说了多少。