会议是不是总被一个人主导?有没有人一直没发言?Fireflies 的 analytics.speakers 给出了每个人的发言时长、提问数和最长独白。 这篇做
talk_time.py:汇总多场会议,生成 Markdown 报告:每人的发言时长、占比、提问数、最长独白和语速,并按人汇总提醒(例如“在 5/5 场会议中发言超过 50%”)。
先看清成品
- 替身上 5 场会议生成报告:王敏发言占 56%,在 5/5 场会议中超过 50%。
- 核对官方文档时发现:duration_pct 是“占整场会议时长”的百分比,各人相加不等于 100;发言占比改用 duration 计算。
- 发现并修复:提醒每场一条太啰嗦;自己算的语速与接口的 words_per_minute 不一致。pytest 3 项通过。
合规提醒:会议录音和转写涉及所有参会者的个人信息。录音前必须告知并取得同意(不同地区法律要求不同);只导出你有权访问的会议,导出的文件妥善保管,分享前确认参会者知情。发言时长报告只用于改进会议,不要用来考核或监控个人。本文只使用 Fireflies 官方 GraphQL API(https://api.fireflies.ai/graphql),key 在 Fireflies 的 Integrations → Fireflies API 中获取,放在 FIREFLIES_API_KEY。
运行说明:box 上没有 Fireflies 账号和 key。我用无效 key 真实调用了 GraphQL 接口(2026-10-21 20:35 和 20:53,UTC+8),得到 HTTP 500 和
{"errors":[{"friendly":true,"code":"auth_failed","message":"An error occurred while authenticating your request. Please recheck your API key and try again. …"}]}。其余响应来自我写的本地替身:字段名按官方文档,57 场会议的内容全是编的(每天 3 场,时间都是 08:00)。
做完之后你会得到什么?
| 成果 | 说明 |
|---|---|
talk_time.py |
汇总和报告 |
ff_export.py / fake_fireflies.py |
复用 A 篇 |
talk_time.md |
结果 |
test_talk.py |
3 项测试 |
需要准备哪些环境?
| 项目 | 说明 |
|---|---|
| Python | 3.9 以上,pip install requests pytest |
| Fireflies key | Integrations → Fireflies API 复制,export FIREFLIES_API_KEY=… |
关键事实
| 事实 | 来源 |
|---|---|
| AnalyticsSpeaker:duration(秒)、word_count、longest_monologue(秒)、monologues_count、filler_words、questions、duration_pct、words_per_minute | 官方 MeetingAnalytics schema |
| duration_pct 是该发言人说话时间占整场会议时长的百分比 | 同上 |
| 限流:Free 每分钟 30 次、每天 500 次;Pro 每分钟 60 次、每天 5000 次;每日额度按 UTC 零点(北京时间 08:00)重置 | 官方 Limits |
文件结构
79-fireflies-C-src/
├── talk_time.py
├── ff_export.py
├── fake_fireflies.py
├── demo_local.py
└── test_talk.py
第 1 步:脚本
"""会议发言时长报告:汇总多场会议 analytics.speakers,看谁说得最多、最长独白、提问数。
单位(官方 MeetingAnalytics 文档):duration、longest_monologue 是秒;duration_pct 是“占整场会议时长”的百分比,
各人相加不等于 100(有沉默),所以“发言占比”用 duration 自己算。"""
import argparse, os, sys
from collections import defaultdict
from ff_export import gql, local, FFError, PAGE
Q = """query Transcripts($limit: Int, $skip: Int, $fromDate: DateTime) {
transcripts(limit: $limit, skip: $skip, fromDate: $fromDate) { id title date duration
analytics { speakers { name duration word_count longest_monologue questions duration_pct words_per_minute } } } }"""
def fetch(key, since=None, **kw):
out, skip = [], 0
while True:
v = {"limit": PAGE, "skip": skip}
if since: v["fromDate"] = since
page = gql(key, Q, v, **kw)["transcripts"]; out += page
if len(page) < PAGE: return out
skip += PAGE
def report(meetings, dominate=0.5, monologue_s=180):
tot = defaultdict(lambda: {"sec": 0.0, "wpm_x_sec": 0.0, "questions": 0, "longest": 0.0, "meetings": 0, "dominated": [], "long": []})
flags = []
for m in meetings:
sp = (m.get("analytics") or {}).get("speakers") or []
talk = sum(s["duration"] for s in sp)
if not sp or talk == 0: flags.append(f"{local(m['date'])} {m['title']}:没有发言分析(可能还在处理或会议太短)"); continue
for s in sp:
t = tot[s["name"]]; t["sec"] += s["duration"]; t["questions"] += s["questions"]
t["wpm_x_sec"] += (s.get("words_per_minute") or 0) * s["duration"] # 用接口给的语速,按发言时长加权
t["longest"] = max(t["longest"], s["longest_monologue"]); t["meetings"] += 1
if s["duration"] / talk > dominate: t["dominated"].append(s["duration"] / talk)
if s["longest_monologue"] > monologue_s: t["long"].append(m["title"])
for n, t in tot.items(): # 按人汇总提醒,而不是每场一条
if t["dominated"]:
flags.append(f"{n} 在 {len(t['dominated'])}/{t['meetings']} 场会议中发言超过 {dominate:.0%}(最高 {max(t['dominated']):.0%})")
if t["long"]:
flags.append(f"{n} 在 {len(t['long'])} 场会议中有超过 {monologue_s / 60:g} 分钟的独白:{'、'.join(t['long'][:3])}")
all_sec = sum(t["sec"] for t in tot.values()) or 1
rows = sorted(({"name": n, **t, "share": t["sec"] / all_sec} for n, t in tot.items()), key=lambda r: -r["sec"])
return rows, flags
def to_md(rows, flags, n):
out = [f"# 发言时长报告({n} 场会议)", "", "| 参会者 | 参加场次 | 发言时长(分钟) | 发言占比 | 提问 | 最长独白(分钟) | 每分钟词数 |",
"| :--- | ---: | ---: | ---: | ---: | ---: | ---: |"]
for r in rows:
wpm = r["wpm_x_sec"] / r["sec"] if r["sec"] else 0
out.append(f"| {r['name']} | {r['meetings']} | {r['sec'] / 60:.1f} | {r['share']:.0%} | {r['questions']} | {r['longest'] / 60:.1f} | {wpm:.0f} |")
if flags: out += ["", "## 值得留意", ""] + [f"- {f}" for f in flags]
return "\n".join(out) + "\n"
def main(argv=None):
ap = argparse.ArgumentParser(); ap.add_argument("--since"); ap.add_argument("-o", default="talk_time.md")
ap.add_argument("--dominate", type=float, default=0.5); a = ap.parse_args(argv)
key = os.environ.get("FIREFLIES_API_KEY") or sys.exit("请先 export FIREFLIES_API_KEY=…")
try: ms = fetch(key, a.since)
except FFError as e: sys.exit(f"错误:{e}")
rows, flags = report(ms, a.dominate)
md = to_md(rows, flags, len(ms)); open(a.o, "w", encoding="utf-8").write(md); print(md)
if __name__ == "__main__":
main()
第 2 步:演示脚本
import os, sys
import fake_fireflies
srv = fake_fireflies.start(); os.environ["FIREFLIES_URL"] = f"http://127.0.0.1:{srv.server_port}/graphql"
import importlib, ff_export; importlib.reload(ff_export)
import talk_time; importlib.reload(talk_time); talk_time.main(sys.argv[1:])
第 3 步:测试
import talk_time as m
def meet(title, speakers, date=1792540800000):
return {"id": title, "title": title, "date": date, "duration": 30,
"analytics": {"speakers": [{"name": n, "duration": d, "word_count": 0, "longest_monologue": lm, "questions": q,
"duration_pct": d / 1800 * 100, "words_per_minute": w} for n, d, lm, q, w in speakers]}}
def test_share_from_duration_not_pct():
rows, _ = m.report([meet("a", [("甲", 600, 60, 1, 150), ("乙", 200, 30, 0, 100)])])
assert abs(rows[0]["share"] - 0.75) < 1e-9 # duration_pct 只有 33%,但发言占比是 75%
def test_flags_aggregated_per_person():
ms = [meet(f"m{i}", [("甲", 600, 200, 0, 150), ("乙", 100, 30, 0, 120)]) for i in range(3)]
_, flags = m.report(ms)
assert len(flags) == 2 and "3/3" in flags[0] and "独白" in flags[1]
def test_weighted_wpm_and_empty():
rows, flags = m.report([meet("a", [("甲", 60, 10, 0, 100)]), meet("b", [("甲", 180, 10, 0, 200)]), {"id": "c", "title": "c", "date": 1792540800000, "analytics": None}])
md = m.to_md(rows, flags, 3)
assert "| 175 |" in md and any("没有发言分析" in f for f in flags)
第 4 步:运行
$ FIREFLIES_API_KEY=demo python demo_local.py --since 2026-10-20T00:00:00+08:00
# 发言时长报告(5 场会议)
| 参会者 | 参加场次 | 发言时长(分钟) | 发言占比 | 提问 | 最长独白(分钟) | 每分钟词数 |
| :--- | ---: | ---: | ---: | ---: | ---: | ---: |
| 王敏 | 5 | 52.0 | 56% | 30 | 2.0 | 150 |
| 李强 | 5 | 25.0 | 27% | 5 | 1.0 | 140 |
| 赵雪 | 5 | 16.7 | 18% | 0 | 0.7 | 145 |
## 值得留意
- 王敏 在 5/5 场会议中发言超过 50%(最高 56%)
$ python -m pytest -q
3 passed
怎么检查结果?
- 发言占比 = 个人 duration ÷ 所有人 duration 之和,不受沉默时间影响。
- 主持人发言多、提问多通常正常;报告只是提示,不下结论。
- 发言人名字来自转写的说话人识别,可能把两个人识别成一个,先抽查。
调试示例
1. 我的替身一开始就理解错了 duration_pct。 我最初让替身里三个人的 duration_pct 加起来正好 100(55 + 27 + 18)。写报告时去查官方 MeetingAnalytics 文档,发现它是“占整场会议时长”的百分比:30 分钟的会,说了 600 秒就是 33%,有沉默时各人加起来不到 100。我改了替身(现在第一场三人合计 59.2%),报告里的“发言占比”改用 duration 自己算。
2. 提醒太多。 第一版每场会议、每个人各报一条:
- 2026-10-21 08:00 周会 #20:王敏 占发言 55%
- 2026-10-21 08:00 周会 #40:王敏 占发言 56%
- 2026-10-20 08:00 周会 #1:王敏 占发言 55%
…(共 5 条)
改为按人汇总:“王敏 在 5/5 场会议中发言超过 50%(最高 56%)”。
3. 语速对不上。 第一版用 word_count ÷ 发言分钟数自己算语速,王敏得到 86,而接口给的 words_per_minute 是 150。原因是我替身里编的 word_count 和 words_per_minute 本身不一致;但这也说明不该自己重算接口已经给出的指标(中文怎么算“词”也不好说)。修复:直接用 words_per_minute,按发言时长加权平均。
常见问题
| 现象 | 原因 | 处理 |
|---|---|---|
| “没有发言分析” | 会议还在处理或太短 | 稍后再跑 |
| 占比和网页上不同 | 网页可能用 duration_pct | 两种口径不同,报告里说明 |
| 同一人出现两次 | 说话人名字不一致 | 在 Fireflies 中修正说话人 |
模板规则
- 先查清字段的单位和口径。
- 能用接口给的指标就不自己重算。
- 提醒按人汇总。
- 报告用于改进会议,不用于监控个人。
项目资源
| 用途 | 地址 |
|---|---|
| Fireflies API 文档(官方) | https://docs.fireflies.ai |
| transcripts 查询(官方) | https://docs.fireflies.ai/graphql-api/query/transcripts |
| 限额与限流(官方) | https://docs.fireflies.ai/fundamentals/limits |
| 错误码(官方) | https://docs.fireflies.ai/miscellaneous/error-codes |
| MeetingAnalytics 字段说明(官方) | https://docs.fireflies.ai/schema/meeting-analytics |
| Fireflies 官网 | https://fireflies.ai |
数据只是镜子,怎么开好会还得大家商量。