
Tingwu Asr
- 56 installs
- 543 repo stars
- Updated August 5, 2026
- cat-xierluo/legal-skills
Transcribes local audio/video via Aliyun Tongyi Tingwu's cloud API with speaker separation, outputting funasr-compatible markdown.
About
Cloud audio/video transcription that wraps Aliyun Tongyi Tingwu's internal REST API and outputs funasr-transcribe-compatible markdown. Developers use it for higher-accuracy or long-audio speech-to-text when local FunASR is unavailable.
- Uploads media to Aliyun OSS then transcribes in the cloud
- Speaker separation and multi-language support
Tingwu Asr by the numbers
- 56 all-time installs (skills.sh)
- Ranked #1,034 of 2,715 Automation & Workflows skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/cat-xierluo/legal-skills --skill tingwu-asrAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 56 |
|---|---|
| repo stars | ★ 543 |
| Last updated | August 5, 2026 |
| Repository | cat-xierluo/legal-skills ↗ |
What it does
Transcribes local audio/video via Aliyun Tongyi Tingwu's cloud API with speaker separation, outputting funasr-compatible markdown.
Files
通义听悟云端转录 (tingwu-asr)
通过逆向封装通义听悟网页端内部 REST API,实现云端音频/视频文件转录,输出与 funasr-transcribe 兼容的 Markdown 格式。
功能
- 上传本地音频/视频文件到阿里云 OSS
- 云端转录,支持说话人分离(单人/2人/多人)
- 支持中文、英文、日文、粤语、中英文混合
- 输出 funasr-transcribe 兼容的 Markdown,可直接用
summary.py注入 AI 总结
依赖
- Python 3.8+
requests(必须) — HTTP 请求oss2(必须) — 阿里云 OSS SDK(STS 直传)
安装:
pip3 install -r skills/tingwu-asr/config/requirements.txt首次使用:登录(通过 MCP Playwright)
登录需要 Agent 使用 MCP Playwright 浏览器工具完成:
1. 用 MCP Playwright 打开 https://tingwu.aliyun.com/home 2. 如果跳转到登录页,用账号密码或扫码登录 3. 登录成功后,用 browser_evaluate 提取 cookie:
() => document.cookie4. 将提取的 cookie 保存到文件:
python3 skills/tingwu-asr/scripts/login.py --save-cookies '{"cna":"xxx","login_aliyunid_ticket":"xxx",...}'账号密码可预配置在 config/.env 文件中(从 config/.env.example 复制)。
每日签到(领取免费额度)
每天登录听悟网页可领取 2 小时免费转录额度。Agent 签到流程:
1. 用 MCP Playwright 打开 https://tingwu.aliyun.com/home(触发每日额度) 2. 提取并保存 Cookie(同登录步骤 3-4) 3. 运行检查脚本确认状态:
python3 skills/tingwu-asr/scripts/daily_checkin.py可在 OpenClaw 中配置定时任务,让 Agent 每天自动执行此流程。
Agent 工作流
当用户要求转录音频/视频文件时,执行以下步骤:
1. 检查登录状态
python3 skills/tingwu-asr/scripts/check_auth.py如果返回"无效",先运行 login.py。
2. 执行转录
# 单文件转录
python3 skills/tingwu-asr/scripts/transcribe.py /path/to/audio.mp3 --lang cn --speakers 4
# 多文件并行转录(自动保存到文件所在目录 + archive 目录)
python3 skills/tingwu-asr/scripts/transcribe.py /path/to/audio1.mp3 /path/to/audio2.mp3 /path/to/video.mp4
# 批量转录目录下所有文件(并行)
python3 skills/tingwu-asr/scripts/transcribe.py /path/to/media_folder/ --batch
# 指定并行数(默认3)
python3 skills/tingwu-asr/scripts/transcribe.py /path/to/audio1.mp3 /path/to/audio2.mp3 --parallel 5参数说明:
paths音频/视频文件路径(支持多个文件并行转录)--lang cn语言: cn(中文,默认) / en(英文) / ja(日文) / cant(粤语) / cn_en(中英混合)--speakers 2说话人: 0(不区分) / 1(单人) / 2(两人,默认) / 4(多人)--batch批量转录目录下所有文件--parallel N并行转录的最大文件数 (默认: 3)--force强制重新上传,即使该文件已有转录结果(默认会跳过已转录的文件)-o output.md指定输出路径(单文件模式)--no-archive不保存归档--no-lab不获取智能分析(关键词/议程/重点等)--ppt下载 PPT 幻灯片图片并嵌入 Markdown(仅视频有效)
3. 输出说明
转录结果会同时保存到两个位置: 1. 源文件所在目录:例如 /path/to/audio.mp3 → /path/to/audio.md 2. archive 归档目录:archive/YYYYMMDD_HHMMSS_audio/audio.md
这样做的好处是:
- 源文件目录方便直接访问
- archive 目录便于集中管理和备份
PPT 幻灯片: 视频文件会自动提取 PPT 幻灯片,图片保存在 {文件名}_slides/ 子目录中(每个文件独立目录,避免同目录下多视频冲突)。
3. 生成 AI 总结(复用 funasr-transcribe)
转录完成后,复用 funasr-transcribe 的 summary 模块:
python3 skills/funasr-transcribe/scripts/summary.py inject transcript.md summary.json
python3 skills/funasr-transcribe/scripts/summary.py verify transcript.md文件结构
skills/tingwu-asr/
SKILL.md ← 本文件
scripts/
tingwu.py ← 核心 API 客户端
transcribe.py ← CLI 入口
format_output.py ← 听悟 JSON → Markdown 转换
login.py ← Cookie 保存工具
daily_checkin.py ← 额度检查 + 记录
check_auth.py ← 认证检查
config/
.env ← 账号密码凭证(gitignore,不提交)
.env.example ← 账号密码模板
cookies.json ← 登录 Cookie(gitignore,不提交)
cookie.example.json ← Cookie 文件模板
quota_history.jsonl ← 额度变更记录(gitignore,不提交)
requirements.txt ← Python 依赖
references/ ← API 文档和决策记录
archive/ ← 转录结果归档异步转录模式(推荐用于长视频)
对于 1 小时以上的长视频,转录可能需要 20-30 分钟。使用异步模式上传后立即返回,后台自动轮询。
1. 异步提交
python3 skills/tingwu-asr/scripts/transcribe.py /path/to/video.mp4 --async --speakers 2上传完成后立即返回任务 ID,任务信息保存到 config/pending_tasks.json。
2. 后台监控(Claude Code 增强模式)
提交后,用 Bash 工具的 run_in_background 启动后台监控:
command: "python3 skills/tingwu-asr/scripts/poll_tasks.py --monitor --timeout 3600 --interval 120"
run_in_background: true
timeout: 600000注意:timeout 必须设为 600000(10 分钟),否则默认 2 分钟会超时。
监控完成后会自动收到通知,此时展示转录结果路径给用户。
3. 手动查询
# 检查所有待处理任务的状态
python3 skills/tingwu-asr/scripts/poll_tasks.py
# 阻塞式监控
python3 skills/tingwu-asr/scripts/poll_tasks.py --monitor注意事项
- Cookie 会过期,过期后需重新运行
login.py - 网页端免费额度有限,大文件或高频使用可能触发风控
- 支持格式: mp3/wav/m4a/wma/aac/ogg/amr/flac/aiff/mp4/wmv/mov/mkv/webm/avi 等
- 音频最大 500M,视频最大 6G,单文件最长 6 小时
config/cookies.json
config/quota_history.jsonl
config/pending_tasks.json
config/completed_tasks.json
.playwright-data/
config/.env
__pycache__/
archive/*
!archive/.gitkeep
slides/
Changelog
All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
[0.2.0] - 2026-04-20
Added
- 多文件并行转录:支持传入多个文件路径,自动并行上传(最大并发数可通过
--parallel参数控制,默认3) - 转录结果双路径保存:结果同时保存到源文件所在目录和 archive 目录
--parallel N参数:指定并行转录的最大文件数
Changed
- CLI 参数
path改为paths,支持多个文件路径 - 批量模式(
--batch)下目录内的文件也会并行处理
[0.1.0] - 2026-04-18
Added
- 核心功能:通过逆向通义听悟网页端 REST API 实现云端音频/视频转录
- 完整 6 步 API 流程:generatePutLink → OSS STS 上传 → syncPutLink → startTrans → 轮询状态 → getTransResult
- 支持语言:中文、英文、日文、粤语、中英文混合
- 说话人分离:不区分 / 单人 / 两人 / 多人
- 输出 funasr-transcribe 兼容的 Markdown 格式
- Playwright Cookie 提取登录(
login.py) - Cookie 认证检查(
check_auth.py) - 批量转录模式(
--batch) - 转录结果归档到
archive/目录 - 复用 funasr-transcribe 的
summary.py注入 AI 总结
# 通义听悟登录凭证
# 用于自动登录刷新 Cookie,每天签到获取免费额度
#
# 填写后重命名为 .env 放到 skills/tingwu-asr/config/ 目录下
# 或设置对应的环境变量
TINGWU_USERNAME=
TINGWU_PASSWORD=
{
"saved_at": "2026-01-01T00:00:00Z",
"cookies": {
"cna": "your_cna_value",
"login_aliyunid_ticket": "your_ticket_value",
"login_aliyunid_pk": "your_pk_value",
"hssid": "your_hssid_value",
"login_aliyunid_csrf": "your_csrf_value",
"atpsida": "your_atpsida_value",
"isg": "your_isg_value"
}
}
requests>=2.28.0
oss2>=2.18.0
MIT License
Copyright (c) 2026 杨卫薪律师
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
通义听悟内部 REST API 参考
通过逆向分析 tingwu.aliyun.com 网页端 JavaScript 和网络请求获取。基础信息
- Base URL:
https://tingwu.aliyun.com/api - 认证方式: Cookie(
.aliyun.com域名,约 22 个 cookie) - 通用请求头:
x-b3-traceid: <UUID> # 随机 UUID
x-b3-sampled: 1
x-tw-canary: # 空值
Content-Type: application/json完整转录流程(6 步)
Step 1: generatePutLink — 获取上传凭证
POST /api/trans/request?generatePutLink请求体:
{
"action": "generatePutLink",
"version": "1.0",
"taskId": "<唯一文件ID>",
"useSts": 1,
"fileSize": 36212345,
"dirId": 0,
"fileContentType": "audio/mpeg",
"tag": {
"showName": "文件名",
"fileFormat": "mp3",
"fileType": "local",
"originalTag": "{\"isVideo\":0}",
"lang": "cn",
"roleSplitNum": 4,
"translateSwitch": false,
"transTargetValue": "",
"originalFlag": 0
}
}视频文件:fileContentType改为视频 MIME 类型(如"video/mp4"),tag.fileFormat改为对应格式(如"mp4"),tag.originalTag改为{"isVideo":1}(音频为{"isVideo":0})。tag.fileType保持"local"不变。
响应:
{
"code": "0",
"data": {
"transId": "4l6xqal2bdkanm2y",
"putLink": "https://...",
"getLink": "https://...",
"sts": {
"endpoint": "https://oss-cn-shanghai.aliyuncs.com",
"accessKeyId": "STS.xxx",
"accessKeySecret": "xxx",
"securityToken": "xxx",
"bucket": "prod-new-tingwu-saas-xxx",
"fileKey": "tingwu/prod/xxx",
"sldEnabled": false
}
}
}Step 2: OSS STS 上传
使用 oss2 SDK:
import oss2
auth = oss2.StsAuth(accessKeyId, accessKeySecret, securityToken)
bucket = oss2.Bucket(auth, endpoint, bucket_name)
bucket.put_object_from_file(fileKey, local_file_path)
# 大文件可用 oss2.resumable_upload() 分片上传Step 3: syncPutLink — 确认上传完成
POST /api/trans/request?syncPutLink请求体:
{
"action": "syncPutLink",
"version": "1.0",
"fileLink": "<putLink URL>",
"fileSize": 36212345,
"transId": "4l6xqal2bdkanm2y",
"duration": 123
}Step 4: startTrans — 启动转录任务
POST /api/trans/request?startTrans请求体:
{
"action": "startTrans",
"version": "1.0",
"userId": "",
"transIds": ["4l6xqal2bdkanm2y"],
"tag": {
"lang": "cn",
"roleSplitNum": 4
}
}Step 5: getTransList — 轮询任务状态
POST /api/trans/request?getTransList请求体:
{
"action": "getTransList",
"version": "1.0",
"userId": "",
"filter": {"status": [1, 2, 3, 4, 11]},
"preview": 1,
"pageNo": 1,
"pageSize": 1000
}状态码: 1=排队, 2=转录中, 3=已完成, 4=失败, 11=上传中
Step 6: getTransResult — 获取转录结果
POST /api/trans/getTransResult?c=web请求体:
{
"action": "getTransResult",
"version": "1.0",
"transId": "4l6xqal2bdkanm2y"
}响应关键字段:
duration: 总时长(秒)wordCount: 总字数status: 0=已完成result: JSON 字符串,解析后为分页结构
`result` 数据结构:
{
"0": [
{
"pi": "1775191379502500000",
"sc": [
{
"bt": 62400,
"et": 62811,
"id": 10,
"si": 1,
"tc": "这会儿"
}
]
}
]
}字段说明:
bt/et: 开始/结束时间(毫秒)si: 说话人 IDtc: 文本内容pi: 段落 ID
辅助 API
| 端点 | 用途 |
|---|---|
GET /api/account/v2/user/info?c=web | 验证登录状态 |
GET /api/tingwu/account/info?c=web | 账户配额信息 |
POST /api/trans/request?delTrans | 删除转录记录 |
参数说明
语言 (lang)
| 值 | 说明 |
|---|---|
cn | 中文(默认) |
en | 英文 |
ja | 日文 |
cant | 粤语 |
cn_en | 中英混合 |
说话人 (roleSplitNum)
| 值 | 说明 |
|---|---|
0 | 不区分 |
1 | 单人 |
2 | 两人 |
4 | 多人(默认) |
文件格式
支持: mp3, wav, m4a, wma, aac, ogg, amr, flac, aiff, mp4, wmv, mov, mkv, webm, avi 等
文件限制
- 音频最大 500MB
- 视频最大 6GB
- 单文件最长 6 小时
#!/usr/bin/env python3
"""检查登录状态和账户信息(纯 HTTP,无需浏览器)"""
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from tingwu import TingwuClient
def main():
try:
client = TingwuClient()
except FileNotFoundError as e:
print(f"错误: {e}")
sys.exit(1)
auth = client.check_auth()
if not auth["valid"]:
print(f"登录状态: 无效 — {auth['error']}")
print("请运行: python3 scripts/login.py 或让 Agent 用 MCP Playwright 登录")
sys.exit(1)
print("登录状态: 有效")
user = auth.get("user", {})
if isinstance(user, dict):
print(f"用户信息: {json.dumps(user, ensure_ascii=False, indent=2)}")
try:
account = client.get_account_info()
print(f"\n账户信息: {json.dumps(account, ensure_ascii=False, indent=2)}")
except Exception as e:
print(f"获取账户信息失败: {e}")
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""每日签到:检查登录状态,记录剩余额度
Agent 工作流:
1. 先用 MCP Playwright 访问 tingwu.aliyun.com 触发每日签到
2. 用 MCP Playwright 提取 cookie,调用 login.py --save-cookies 保存
3. 运行本脚本检查登录状态和额度
本脚本只做纯 HTTP 部分(步骤 3)。
"""
import json
import sys
import time
from pathlib import Path
SKILL_ROOT = Path(__file__).resolve().parent.parent
COOKIE_PATH = SKILL_ROOT / "config" / "cookies.json"
QUOTA_LOG = SKILL_ROOT / "config" / "quota_history.jsonl"
def main():
sys.path.insert(0, str(Path(__file__).resolve().parent))
from tingwu import TingwuClient
try:
client = TingwuClient()
except FileNotFoundError as e:
print(f"错误: {e}")
print("请先用 MCP Playwright 登录并保存 Cookie")
sys.exit(1)
auth = client.check_auth()
if not auth["valid"]:
print(f"登录无效: {auth['error']}")
print("请先用 MCP Playwright 重新登录")
sys.exit(1)
print("登录状态: 有效")
user = auth.get("user", {})
name = user.get("aliyunUserName") or user.get("displayName", "")
if name:
print(f"用户: {name}")
try:
account = client.get_account_info()
print(f"账户信息: {json.dumps(account, ensure_ascii=False, indent=2)}")
_log_quota(account)
except Exception as e:
print(f"获取额度失败: {e}")
print("签到完成!")
def _log_quota(account):
QUOTA_LOG.parent.mkdir(parents=True, exist_ok=True)
entry = {
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"account": account,
}
with open(QUOTA_LOG, "a", encoding="utf-8") as f:
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""将通义听悟 API 返回的 JSON 结果转换为 funasr-transcribe 兼容的 Markdown 格式"""
import json
from datetime import datetime
from pathlib import Path
def format_timestamp(ms):
"""毫秒 → MM:SS 或 HH:MM:SS"""
total_sec = max(0, ms) // 1000
h, remainder = divmod(total_sec, 3600)
m, s = divmod(remainder, 60)
if h > 0:
return f"{h:02d}:{m:02d}:{s:02d}"
return f"{m:02d}:{s:02d}"
def parse_result(result_data):
"""解析听悟 result JSON 字符串为结构化段落列表"""
if isinstance(result_data, str):
result_data = json.loads(result_data)
pages = []
if "pg" in result_data:
pages = result_data["pg"]
else:
for key, value in result_data.items():
if isinstance(value, list):
pages.extend(value)
paragraphs = []
for page in pages:
if not isinstance(page, dict) or "sc" not in page:
continue
segments = page["sc"]
if not segments:
continue
# ui 是段落级说话人ID("1"/"2"/...),si 是句子级递增编号
page_speaker = int(page.get("ui", 0)) or None
current_start = segments[0].get("bt", 0)
current_text = []
for seg in segments:
text = seg.get("tc", "")
current_text.append(text)
if current_text:
paragraphs.append({
"speaker": page_speaker,
"start_ms": current_start,
"text": "".join(current_text),
})
return paragraphs
def merge_speaker_segments(paragraphs, max_gap_ms=30000):
"""合并连续相同说话人的短句(30 秒窗口内)"""
if not paragraphs:
return paragraphs
merged = [paragraphs[0]]
for p in paragraphs[1:]:
last = merged[-1]
if (p["speaker"] == last["speaker"]
and p["start_ms"] - last["start_ms"] <= max_gap_ms):
last["text"] += p["text"]
else:
merged.append(p)
return merged
def consolidate_speakers(paragraphs, max_speakers=None):
"""将 Tingwu 的轮次 ID(si)合并为实际说话人。
Tingwu 的 si 是递增轮次编号,不是说话人编号。
对于 2 人对话,按奇偶交替映射到 发言人1/发言人2。
对于多人场景,保留原始 si 但合并相近的段。
"""
if not paragraphs:
return paragraphs
unique_si = sorted(set(p["speaker"] for p in paragraphs))
if max_speakers and max_speakers == 2 and len(unique_si) > 2:
# 2人模式:奇偶交替映射
for p in paragraphs:
p["speaker"] = (p["speaker"] - 1) % 2 + 1
elif max_speakers and max_speakers == 1:
# 单人模式:全部合并
for p in paragraphs:
p["speaker"] = 1
return paragraphs
def normalize_speakers(paragraphs):
"""将说话人 ID 映射为 发言人1, 发言人2..."""
speaker_map = {}
counter = 1
for p in paragraphs:
sid = p["speaker"]
if sid not in speaker_map:
speaker_map[sid] = f"发言人{counter}"
counter += 1
p["speaker_name"] = speaker_map[sid]
def interleave_ppt_slides(paragraphs, slides):
"""将 PPT 幻灯片按时间戳插入到转录段落中。
算法:对每张幻灯片(时间 T),找到第一条 start_ms >= T 的段落,
在该段落前插入幻灯片图片引用。
"""
if not slides or not paragraphs:
return paragraphs
slides_by_time = sorted(slides, key=lambda s: s["time"])
para_idx = 0
interleaved = []
for slide in slides_by_time:
# 找到第一条 start_ms >= slide.time 的段落
while para_idx < len(paragraphs) and paragraphs[para_idx]["start_ms"] < slide["time"]:
interleaved.append(paragraphs[para_idx])
para_idx += 1
# 插入幻灯片标记
interleaved.append({"_ppt_slide": True, **slide})
# 追加剩余段落
interleaved.extend(paragraphs[para_idx:])
return interleaved
def result_to_markdown(result_data, file_name, duration=None, word_count=None,
max_speakers=None, ppt_slides=None, slides_dir_name="slides",
slides_ext=".png"):
"""将听悟转录结果转换为 funasr 兼容的 Markdown"""
paragraphs = parse_result(result_data)
paragraphs = consolidate_speakers(paragraphs, max_speakers=max_speakers)
paragraphs = merge_speaker_segments(paragraphs)
normalize_speakers(paragraphs)
if ppt_slides:
paragraphs = interleave_ppt_slides(paragraphs, ppt_slides)
lines = [f"# 转录:{file_name}", ""]
if duration:
m, s = divmod(duration, 60)
lines.append(f"> 时长: {m:.0f}分{s:.0f}秒 | 字数: {word_count or 'N/A'} | 引擎: 听悟")
lines.append("")
lines.append("## 转录内容")
lines.append("")
for p in paragraphs:
if p.get("_ppt_slide"):
idx = p.get("index", 0)
img_rel = f"./{slides_dir_name}/slide_{idx:03d}{slides_ext}"
ts = format_timestamp(p["time"])
lines.append(f"")
lines.append(f"> *{ts}*")
lines.append("")
else:
ts = format_timestamp(p["start_ms"])
lines.append(f"{p['speaker_name']} {ts}")
lines.append(f"{p['text']}")
lines.append("")
return "\n".join(lines)
def lab_to_markdown(lab_data):
"""将 getLabInfo 返回的数据格式化为 Markdown(追加到转录末尾)"""
if not lab_data or not isinstance(lab_data, dict):
return ""
cards = lab_data.get("labCards", [])
card_map = {}
for card in cards:
name = card.get("basicInfo", {}).get("name", "")
card_map[name] = card
lines = ["", "---", ""]
# 关键词
keywords_card = card_map.get("关键词")
if keywords_card:
for content in keywords_card.get("contents", []):
if content.get("type") == "tag":
words = [v["value"] for v in content.get("contentValues", [])]
if words:
lines.append("## 关键词")
lines.append("")
lines.append("、".join(words))
lines.append("")
# 议程
agenda_card = card_map.get("议程")
if agenda_card:
for content in agenda_card.get("contents", []):
items = content.get("contentValues", [])
if items:
lines.append("## 议程摘要")
lines.append("")
for item in items:
if not item.get("value"):
continue
start = format_timestamp(item.get("time", 0))
end = format_timestamp(item.get("endTime", 0))
lines.append(f"### {item['value']}")
lines.append(f"> {start} - {end}")
lines.append("")
summary = item.get("summary")
if summary:
lines.append(summary.strip())
lines.append("")
# 重点内容
keypoints_card = card_map.get("重点内容")
if keypoints_card:
for content in keypoints_card.get("contents", []):
items = content.get("contentValues", [])
if items:
lines.append("## 重点内容")
lines.append("")
for item in items:
if not item.get("value"):
continue
ts = format_timestamp(item.get("time", 0))
lines.append(f"- \"{item['value']}\" ({ts})")
lines.append("")
# QA 问答
qa_card = card_map.get("qa问答")
if qa_card:
for content in qa_card.get("contents", []):
items = content.get("contentValues", [])
if items:
lines.append("## Q&A 问答")
lines.append("")
for item in items:
question = item.get("title", "").strip()
answer = item.get("value", "").strip()
if question:
lines.append(f"**Q: {question}**")
if answer:
lines.append(f"A: {answer}")
lines.append("")
# PPT 标题(视频场景)
ppt_card = card_map.get("ppt列表标题")
if ppt_card:
for content in ppt_card.get("contents", []):
items = content.get("contentValues", [])
if items:
lines.append("## PPT 章节标题")
lines.append("")
for item in items:
if not item.get("value"):
continue
start = format_timestamp(item.get("time", 0))
end = format_timestamp(item.get("endTime", 0))
lines.append(f"{item['id']}. {item['value']} ({start} - {end})")
lines.append("")
result = "\n".join(lines)
return result if len(result) > 20 else ""
def save_archive(file_path, markdown_content, trans_id, result_data, archive_root):
"""保存转录结果到归档目录"""
now = datetime.now()
dir_name = f"{now.strftime('%Y%m%d_%H%M%S')}_{Path(file_path).stem}"
archive_dir = Path(archive_root) / dir_name
archive_dir.mkdir(parents=True, exist_ok=True)
md_path = archive_dir / f"{Path(file_path).stem}.md"
md_path.write_text(markdown_content, encoding="utf-8")
meta = {
"source_file": str(file_path),
"output_markdown": str(md_path),
"archive_path": str(archive_dir),
"timestamp": now.isoformat(),
"engine": "tingwu",
"task_id": trans_id,
"duration": result_data.get("duration"),
"word_count": result_data.get("wordCount"),
}
meta_path = archive_dir / "transcription_meta.json"
meta_path.write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8")
return md_path, archive_dir
#!/usr/bin/env python3
"""保存 Cookie 到 config/cookies.json
由 Agent 通过 MCP Playwright 提取 cookie 后调用:
python3 scripts/login.py --save-cookies '{"cna":"xxx","login_aliyunid_ticket":"xxx",...}'
也可以直接传入 cookie JSON 文件路径:
python3 scripts/login.py --cookie-file /path/to/cookies.json
"""
import json
import sys
import time
from pathlib import Path
SKILL_ROOT = Path(__file__).resolve().parent.parent
COOKIE_PATH = SKILL_ROOT / "config" / "cookies.json"
def save_cookies(cookie_map):
"""保存 cookie 字典到文件"""
if isinstance(cookie_map, str):
cookie_map = json.loads(cookie_map)
data = {
"saved_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"cookies": cookie_map,
}
COOKIE_PATH.parent.mkdir(parents=True, exist_ok=True)
COOKIE_PATH.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"已保存 {len(cookie_map)} 个 Cookie 到 {COOKIE_PATH}")
def main():
import argparse
parser = argparse.ArgumentParser(description="通义听悟 Cookie 管理")
parser.add_argument("--save-cookies", help="直接传入 cookie JSON 字符串")
parser.add_argument("--cookie-file", help="从文件读取 cookie JSON")
args = parser.parse_args()
if args.save_cookies:
save_cookies(args.save_cookies)
elif args.cookie_file:
with open(args.cookie_file, encoding="utf-8") as f:
save_cookies(json.load(f))
else:
print(f"用法: {sys.argv[0]} --save-cookies '<json>' 或 --cookie-file <path>")
sys.exit(1)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""tingwu-asr 异步任务轮询 — 检查 pending 任务状态,完成后自动生成 Markdown"""
import argparse
import json
import subprocess
import sys
import time
from datetime import datetime
from pathlib import Path
SKILL_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(Path(__file__).resolve().parent))
from tingwu import TingwuClient, VIDEO_EXTS
from format_output import result_to_markdown, lab_to_markdown, save_archive
PENDING_PATH = SKILL_ROOT / "config" / "pending_tasks.json"
COMPLETED_PATH = SKILL_ROOT / "config" / "completed_tasks.json"
STATUS_NAMES = {0: "已完成", 1: "排队中", 2: "转录中", 3: "已完成", 4: "失败", 11: "上传中"}
def load_tasks(path):
if path.exists():
return json.loads(path.read_text(encoding="utf-8"))
return []
def save_tasks(path, tasks):
path.write_text(json.dumps(tasks, ensure_ascii=False, indent=2), encoding="utf-8")
def finish_task(client, task):
"""任务完成:获取结果 → 生成 Markdown → PPT → 智能分析 → 归档"""
trans_id = task["trans_id"]
file_name = task["file_name"]
speakers = task.get("role_split_num", 4)
print(f" 获取转录结果...")
trans_result = client.get_trans_result(trans_id)
# PPT(视频自动启用)
ppt_slides = None
slides_ext = ".png"
is_video = Path(task["file_path"]).suffix.lower() in VIDEO_EXTS
if task.get("ppt") or is_video:
try:
print(f" 获取 PPT 幻灯片...")
ppt_slides = client.get_ppt_info(trans_id)
if ppt_slides:
file_stem = Path(task["file_path"]).stem
out_dir = Path(task["output_path"]).parent if task.get("output_path") else Path(task["file_path"]).parent
client.download_ppt_images(ppt_slides, out_dir, file_stem=file_stem)
slides_dir = out_dir / f"{file_stem}_slides"
slides_ext = client.compress_slides(slides_dir)
print(f" 已下载 {len(ppt_slides)} 张幻灯片到 {file_stem}_slides/")
except Exception as e:
print(f" PPT 下载失败: {e}")
file_stem = Path(task["file_path"]).stem
md = result_to_markdown(
trans_result.get("result", "{}"),
file_name,
duration=trans_result.get("duration"),
word_count=trans_result.get("wordCount"),
max_speakers=speakers,
ppt_slides=ppt_slides,
slides_dir_name=f"{file_stem}_slides" if ppt_slides else "slides",
slides_ext=slides_ext,
)
if task.get("output_path"):
out_path = Path(task["output_path"])
else:
out_path = Path(task["file_path"]).with_suffix(".md")
out_path.write_text(md, encoding="utf-8")
print(f" 转录完成: {out_path}")
# 智能分析
if not task.get("no_lab"):
try:
lab_data = client.get_lab_info(trans_id)
lab_md = lab_to_markdown(lab_data)
if lab_md:
md += lab_md
out_path.write_text(md, encoding="utf-8")
except Exception:
pass
# 归档
if not task.get("no_archive"):
archive_root = SKILL_ROOT / "archive"
save_archive(Path(task["file_path"]), md, trans_id, trans_result, archive_root)
# 自动 AI 总结
if not task.get("no_summary"):
try:
summary_py = SKILL_ROOT.parent / "funasr-transcribe" / "scripts" / "summary.py"
if summary_py.exists():
print(f" 生成 AI 总结...")
subprocess.run(
[
"python3", str(summary_py), "inject",
str(out_path), str(out_path.with_suffix(".json")),
],
check=False, timeout=120,
)
print(f" AI 总结已生成")
except Exception as e:
print(f" AI 总结失败: {e}")
return {
"output_path": str(out_path),
"duration": trans_result.get("duration"),
"word_count": trans_result.get("wordCount"),
}
def check_once(client):
"""检查所有 pending 任务的状态,完成的自动处理"""
tasks = load_tasks(PENDING_PATH)
if not tasks:
print("无待处理任务")
return []
completed = []
remaining = []
for task in tasks:
trans_id = task["trans_id"]
try:
info = client.get_trans_list(trans_id)
except Exception as e:
print(f"[{trans_id}] 查询失败: {e}")
remaining.append(task)
continue
if info is None:
print(f"[{trans_id}] 任务未出现在列表中")
remaining.append(task)
continue
status = info.get("status", -1)
name = STATUS_NAMES.get(status, f"未知({status})")
if status in (0, 3):
print(f"[{trans_id}] {name} — 正在生成输出...")
try:
result_info = finish_task(client, task)
task["status"] = "completed"
task["completed_at"] = datetime.now().isoformat()
task["result"] = result_info
completed.append(task)
except Exception as e:
print(f"[{trans_id}] 生成输出失败: {e}")
task["status"] = "error"
task["error"] = str(e)
completed.append(task)
elif status == 4:
print(f"[{trans_id}] 失败: {info.get('statusMsg', '未知原因')}")
task["status"] = "failed"
task["error"] = info.get("statusMsg", "未知原因")
completed.append(task)
else:
extra = ""
forecast = info.get("forecastTransDoneTime")
now = info.get("serverCurrentTime")
if forecast and now and status in (1, 2):
remain_s = max(0, (forecast - now) / 1000)
extra = f" | 预计剩余: {remain_s / 60:.1f} 分钟"
print(f"[{trans_id}] {name}{extra}")
remaining.append(task)
save_tasks(PENDING_PATH, remaining)
if completed:
existing = load_tasks(COMPLETED_PATH)
existing.extend(completed)
save_tasks(COMPLETED_PATH, existing)
return completed
def monitor_loop(client, timeout=3600, interval=120):
"""阻塞式循环轮询,直到所有任务完成或超时"""
start = time.time()
while time.time() - start < timeout:
tasks = load_tasks(PENDING_PATH)
if not tasks:
print("所有任务已完成")
return True
print(f"\n--- {datetime.now().strftime('%H:%M:%S')} 检查 {len(tasks)} 个待处理任务 ---")
completed = check_once(client)
tasks = load_tasks(PENDING_PATH)
if not tasks:
print("\n全部转录完成!")
return True
elapsed = int(time.time() - start)
print(f"等待 {interval} 秒后重试(已耗时 {elapsed}s)...")
time.sleep(interval)
print(f"\n轮询超时 ({timeout}s),仍有 {len(load_tasks(PENDING_PATH))} 个任务未完成")
return False
def main():
parser = argparse.ArgumentParser(description="tingwu-asr 异步任务轮询")
parser.add_argument("--monitor", action="store_true", help="阻塞式循环轮询")
parser.add_argument("--timeout", type=int, default=3600, help="监控超时秒数 (默认: 3600)")
parser.add_argument("--interval", type=int, default=120, help="轮询间隔秒数 (默认: 120)")
parser.add_argument("--cookie", help="Cookie 文件路径")
args = parser.parse_args()
try:
client = TingwuClient(cookie_path=args.cookie)
except FileNotFoundError as e:
print(f"错误: {e}")
sys.exit(1)
if args.monitor:
ok = monitor_loop(client, timeout=args.timeout, interval=args.interval)
sys.exit(0 if ok else 1)
else:
check_once(client)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""通义听悟内部 API 客户端 — 纯 HTTP,无需浏览器"""
import json
import os
import sys
import time
import uuid
from pathlib import Path
try:
import requests
except ImportError:
print("错误: 缺少 requests 库。请运行: pip3 install requests")
sys.exit(1)
try:
import oss2
except ImportError:
oss2 = None
BASE_URL = "https://tingwu.aliyun.com/api"
SKILL_ROOT = Path(__file__).resolve().parent.parent
COOKIE_PATH = SKILL_ROOT / "config" / "cookies.json"
AUDIO_EXTS = {".mp3", ".wav", ".m4a", ".wma", ".aac", ".ogg", ".amr", ".flac", ".aiff"}
VIDEO_EXTS = {".mp4", ".wmv", ".m4v", ".flv", ".rmvb", ".dat", ".mov", ".mkv", ".webm", ".avi", ".mpeg", ".3gp"}
ALL_EXTS = AUDIO_EXTS | VIDEO_EXTS
LANG_MAP = {"cn": "cn", "zh": "cn", "中文": "cn", "en": "en", "英文": "en", "英语": "en",
"ja": "ja", "日文": "ja", "日语": "ja", "cant": "cant", "粤语": "cant",
"cn_en": "cn_en", "中英": "cn_en", "中英文": "cn_en"}
MIME_MAP = {
".mp3": "audio/mpeg", ".wav": "audio/wav", ".m4a": "audio/mp4",
".aac": "audio/aac", ".ogg": "audio/ogg", ".flac": "audio/flac",
".mp4": "video/mp4", ".avi": "video/x-msvideo", ".mov": "video/quicktime",
".mkv": "video/x-matroska", ".webm": "video/webm",
}
def _trace_id():
return uuid.uuid4().hex[:32]
class TingwuClient:
def __init__(self, cookie_path=None):
self.cookie_path = Path(cookie_path) if cookie_path else COOKIE_PATH
self.session = requests.Session()
self._load_cookies()
def _load_cookies(self):
if not self.cookie_path.exists():
raise FileNotFoundError(
f"Cookie 文件不存在: {self.cookie_path}\n"
"请先运行: python3 scripts/login.py"
)
with open(self.cookie_path) as f:
data = json.load(f)
cookies = data.get("cookies", data)
for name, value in cookies.items():
self.session.cookies.set(name, value, domain=".aliyun.com")
def _headers(self):
return {
"Content-Type": "application/json",
"Accept": "application/json, text/plain, */*",
"x-b3-traceid": _trace_id(),
"x-b3-spanid": _trace_id()[:16],
"x-b3-sampled": "1",
"x-tw-canary": "",
"Referer": "https://tingwu.aliyun.com/home",
}
def _post(self, path, body, params=None):
url = f"{BASE_URL}{path}"
resp = self.session.post(url, json=body, headers=self._headers(), params=params, timeout=60)
resp.raise_for_status()
data = resp.json()
if data.get("code") != "0":
raise RuntimeError(f"API 错误 [{data.get('code')}]: {data.get('message')}")
return data.get("data", data)
def _get(self, path, params=None):
url = f"{BASE_URL}{path}"
resp = self.session.get(url, headers=self._headers(), params=params, timeout=30)
resp.raise_for_status()
data = resp.json()
if data.get("code") != "0":
raise RuntimeError(f"API 错误 [{data.get('code')}]: {data.get('message')}")
return data.get("data", data)
# --- Step 0: 认证检查 ---
def check_auth(self):
try:
info = self._get("/account/v2/user/info", {"c": "web"})
return {"valid": True, "user": info}
except Exception as e:
return {"valid": False, "error": str(e)}
def get_account_info(self):
return self._get("/tingwu/account/info", {"c": "web"})
# --- Step 1: 获取上传凭证 ---
def generate_put_link(self, file_path, lang="cn", role_split_num=4):
path = Path(file_path)
ext = path.suffix.lower()
if ext not in ALL_EXTS:
raise ValueError(f"不支持的文件格式: {ext},支持: {', '.join(sorted(ALL_EXTS))}")
file_size = path.stat().st_size
file_name = path.stem[:150]
file_type = "video" if ext in VIDEO_EXTS else "audio"
is_video = ext in VIDEO_EXTS
content_type = MIME_MAP.get(ext, "application/octet-stream")
body = {
"action": "generatePutLink",
"version": "1.0",
"taskId": f"local-{uuid.uuid4().hex[:16]}",
"useSts": 1,
"fileSize": file_size,
"dirId": 0,
"fileContentType": content_type,
"tag": {
"showName": file_name,
"fileFormat": ext.lstrip("."),
"fileType": "local",
"lang": lang,
"roleSplitNum": role_split_num,
"translateSwitch": False,
"transTargetValue": "",
"originalFlag": 0,
"originalTag": json.dumps({"isVideo": 1 if is_video else 0}),
},
}
result = self._post("/trans/request", body, params={"generatePutLink": ""})
return {
"transId": result.get("transId"),
"putLink": result.get("putLink"),
"getLink": result.get("getLink"),
"sts": result.get("sts"),
"file_size": file_size,
"file_name": file_name,
"is_video": is_video,
}
# --- Step 2: 上传文件到 OSS ---
def upload_to_oss(self, file_path, put_link_result):
sts = put_link_result["sts"]
if not sts:
raise RuntimeError("generatePutLink 返回的 sts 为空")
file_path = str(file_path)
if oss2 is not None:
auth = oss2.StsAuth(
sts["accessKeyId"],
sts["accessKeySecret"],
sts["securityToken"],
)
bucket = oss2.Bucket(auth, sts["endpoint"], sts["bucket"])
print(f" 正在上传到 OSS (使用 oss2 SDK)...")
oss2.resumable_upload(
bucket,
sts["fileKey"],
file_path,
progress_callback=_oss_progress,
num_threads=4,
)
else:
_upload_via_requests(file_path, put_link_result)
print(" 上传完成")
# --- Step 3: 确认上传 ---
def sync_put_link(self, put_link_result, duration=None):
file_link = put_link_result.get("putLink") or put_link_result.get("getLink", "")
body = {
"action": "syncPutLink",
"version": "1.0",
"fileLink": file_link,
"fileSize": put_link_result["file_size"],
"transId": put_link_result["transId"],
}
if duration:
body["duration"] = duration
return self._post("/trans/request", body, params={"syncPutLink": ""})
# --- Step 4: 启动转录 ---
def start_trans(self, trans_ids, lang="cn", role_split_num=4):
if isinstance(trans_ids, str):
trans_ids = [trans_ids]
body = {
"action": "startTrans",
"version": "1.0",
"userId": "",
"transIds": trans_ids,
"tag": {"lang": lang, "roleSplitNum": role_split_num},
}
return self._post("/trans/request", body, params={"startTrans": ""})
# --- Step 5: 轮询状态 ---
def get_trans_list(self, trans_id=None):
body = {
"action": "getTransList",
"version": "1.0",
"userId": "",
"filter": {"status": [0, 1, 2, 3, 4, 11]},
"preview": 1,
"pageNo": 1,
"pageSize": 1000,
}
result = self._post("/trans/request", body, params={"getTransList": ""})
if trans_id and isinstance(result, list):
for item in result:
if item.get("transId") == trans_id:
return item
return None
return result
def poll_until_done(self, trans_id, interval=10, timeout=3600):
start = time.time()
status_names = {0: "已完成", 1: "排队中", 2: "转录中", 3: "已完成", 4: "失败", 11: "上传中"}
while time.time() - start < timeout:
try:
info = self.get_trans_list(trans_id)
if info is None:
elapsed = time.time() - start
print(f"\r 任务暂未出现在列表中 (已等待 {elapsed:.0f}s) ", end="", flush=True)
time.sleep(interval)
continue
status = info.get("status", -1)
name = status_names.get(status, f"未知({status})")
extra = ""
forecast = info.get("forecastTransDoneTime")
now = info.get("serverCurrentTime")
if forecast and now and status in (1, 2):
remain_s = max(0, (forecast - now) / 1000)
remain_m = remain_s / 60
extra = f" | 预计剩余: {remain_m:.1f} 分钟"
duration = info.get("duration")
if duration:
extra += f" | 音频时长: {duration / 60:.0f} 分钟"
print(f"\r 转录状态: {name}{extra} ", end="", flush=True)
if status in (0, 3):
print()
return info
if status == 4:
print()
raise RuntimeError(f"转录失败: {info.get('statusMsg', '未知原因')}")
except RuntimeError:
raise
except Exception as e:
print(f"\n 查询异常: {e}")
time.sleep(interval)
raise TimeoutError(f"转录超时 ({timeout}秒)")
# --- Step 6: 获取结果 ---
def get_trans_result(self, trans_id):
body = {
"action": "getTransResult",
"version": "1.0",
"transId": trans_id,
}
return self._post("/trans/getTransResult", body, params={"c": "web"})
# --- 完整流程 ---
def transcribe(self, file_path, lang="cn", role_split_num=4, poll_interval=10, poll_timeout=1800):
print("[1/5] 获取上传凭证...")
put_link = self.generate_put_link(file_path, lang=lang, role_split_num=role_split_num)
trans_id = put_link["transId"]
print(f" 任务ID: {trans_id}")
print("[2/5] 上传文件到 OSS...")
self.upload_to_oss(file_path, put_link)
print("[3/5] 确认上传(自动启动转录)...")
self.sync_put_link(put_link)
print("[4/5] 等待转录完成...")
task_info = self.poll_until_done(trans_id, interval=poll_interval, timeout=poll_timeout)
print("[5/5] 获取转录结果...")
result = self.get_trans_result(trans_id)
return {
"trans_id": trans_id,
"task_info": task_info,
"result": result,
"duration": result.get("duration"),
"word_count": result.get("wordCount"),
}
def submit_transcribe(self, file_path, lang="cn", role_split_num=4):
"""上传文件并启动转录,不等待结果。返回任务信息供异步轮询。"""
print("[1/3] 获取上传凭证...")
put_link = self.generate_put_link(file_path, lang=lang, role_split_num=role_split_num)
trans_id = put_link["transId"]
print(f" 任务ID: {trans_id}")
print("[2/3] 上传文件到 OSS...")
self.upload_to_oss(file_path, put_link)
print("[3/3] 确认上传(自动启动转录)...")
self.sync_put_link(put_link)
return {
"trans_id": trans_id,
"file_name": Path(file_path).name,
"lang": lang,
"role_split_num": role_split_num,
}
# --- 智能分析 (Lab) ---
def get_lab_info(self, trans_id):
body = {"action": "getLabInfo", "version": "1.0", "transId": trans_id}
return self._post("/lab/request", body, params={"getLabInfo": ""})
def get_ppt_info(self, trans_id):
"""获取 PPT 提取结果(含每张幻灯片的图片 URL 和摘要)"""
body = {
"action": "getAllLabInfo",
"content": ["labPptInfo"],
"transId": trans_id,
}
url = f"{BASE_URL}/lab/getAllLabInfo"
resp = self.session.post(url, json=body, headers=self._headers(), params={"c": "web"}, timeout=30)
resp.raise_for_status()
data = resp.json()
if data.get("code") != "0":
raise RuntimeError(f"API 错误 [{data.get('code')}]: {data.get('message')}")
result = data.get("data", data)
cards = result.get("labCardsMap", {}).get("labPptInfo", [])
slides = []
for card in cards:
for content in card.get("contents", []):
for item in content.get("contentValues", []):
slides.append({
"index": item.get("index", 0),
"time": item.get("time", 0),
"image_url": item.get("pictureOssLink", ""),
"thumbnail_url": item.get("pictureThumbnailOssLink", ""),
"summary": item.get("pptSummary", ""),
"id": item.get("id"),
})
slides.sort(key=lambda s: s["time"])
return slides
def download_ppt_images(self, slides, output_dir, file_stem=None):
"""下载 PPT 幻灯片图片到指定目录。file_stem 非空时使用 {stem}_slides 子目录避免冲突。"""
dir_name = f"{file_stem}_slides" if file_stem else "slides"
slides_dir = Path(output_dir) / dir_name
slides_dir.mkdir(parents=True, exist_ok=True)
downloaded = []
for slide in slides:
if not slide["image_url"]:
continue
img_path = slides_dir / f"slide_{slide['index']:03d}.png"
if img_path.exists():
slide["local_path"] = img_path
downloaded.append(slide)
continue
print(f" 下载幻灯片 {slide['index']}/{len(slides)}...")
resp = self.session.get(slide["image_url"], timeout=60)
resp.raise_for_status()
img_path.write_bytes(resp.content)
slide["local_path"] = img_path
downloaded.append(slide)
return downloaded
def compress_slides(self, slides_dir, target_kb=100):
"""将 slides_dir 中的 PNG 图片压缩为 WebP 格式。
逐张适配质量参数,确保每张图片在 target_kb KB 以内。
压缩后删除原始 PNG 文件,返回新扩展名 '.webp'。
"""
try:
from PIL import Image
except ImportError:
print(" 跳过压缩: 需要 Pillow 库 (pip3 install Pillow)")
return ".png"
slides_dir = Path(slides_dir)
png_files = sorted(slides_dir.glob("slide_*.png"))
if not png_files:
return ".png"
print(f" 压缩 {len(png_files)} 张幻灯片 (目标 <{target_kb}KB)...")
compressed = 0
for png_path in png_files:
webp_path = png_path.with_suffix(".webp")
try:
img = Image.open(png_path)
# 跳过已经小于目标的文件
if png_path.stat().st_size <= target_kb * 1024:
# 重命名为 webp 但保持 PNG 数据(实际上保留原文件即可)
png_path.rename(webp_path)
# 真正转一下格式以保持一致性
img.save(str(webp_path), "WEBP", quality=95, lossless=False)
compressed += 1
continue
# 二分搜索最佳质量:从高到低尝试
lo, hi = 50, 95
best_quality = hi
while lo <= hi:
mid = (lo + hi) // 2
img.save(str(webp_path), "WEBP", quality=mid, lossless=False)
if webp_path.stat().st_size <= target_kb * 1024:
best_quality = mid
hi = mid - 1
else:
lo = mid + 1
# 用找到的最佳质量重新保存
img.save(str(webp_path), "WEBP", quality=best_quality, lossless=False)
png_path.unlink()
compressed += 1
except Exception as e:
print(f" 压缩失败 {png_path.name}: {e}")
continue
if compressed:
total = sum(f.stat().st_size for f in slides_dir.glob("slide_*.webp"))
print(f" 压缩完成: {compressed} 张, 总计 {total / 1024 / 1024:.1f}MB")
# 压缩后更新 Markdown 中的图片引用(.png → .webp)
self._update_md_slide_refs(slides_dir, ".webp")
return ".webp"
def _update_md_slide_refs(self, slides_dir, new_ext):
"""压缩后自动更新 Markdown 文件中的图片引用格式。
根据 slides 目录名推导对应的 .md 文件并替换引用。
"""
import re
slides_dir = Path(slides_dir)
dir_name = slides_dir.name
# 推导 Markdown 文件路径:{stem}_slides → {stem}.md
md_path = None
if dir_name.endswith("_slides"):
stem = dir_name[:-7]
candidate = slides_dir.parent / f"{stem}.md"
if candidate.exists():
md_path = candidate
# 回退:扫描父目录下所有引用了该 slides 目录的 .md 文件
if not md_path:
for md_file in slides_dir.parent.glob("*.md"):
content = md_file.read_text(encoding="utf-8")
if f"./{dir_name}/slide_" in content:
md_path = md_file
break
if not md_path:
return
content = md_path.read_text(encoding="utf-8")
# 精确替换:./{dir_name}/slide_XXX.png → ./{dir_name}/slide_XXX.webp
updated = re.sub(
rf'(\./{re.escape(dir_name)}/slide_\d{{3}})\.png\)',
rf'\1{new_ext})',
content,
)
if updated != content:
md_path.write_text(updated, encoding="utf-8")
count = len(re.findall(rf'\./{re.escape(dir_name)}/slide_\d{{3}}{re.escape(new_ext)}\)', updated))
print(f" 已更新 {md_path.name} 中 {count} 处图片引用 (.png → {new_ext})")
# --- 删除任务 ---
def delete_trans(self, trans_ids, permanently=False):
if isinstance(trans_ids, str):
trans_ids = [trans_ids]
body = {
"action": "delTrans",
"version": "1.0",
"userId": "",
"transIds": trans_ids,
"deletePermanently": permanently,
}
return self._post("/trans/request", body, params={"delTrans": ""})
def _oss_progress(consumed, total):
if total:
pct = int(consumed / total * 100)
print(f"\r 上传进度: {pct}%", end="", flush=True)
def _upload_via_requests(file_path, put_link_result):
"""备用上传方式:通过 PUT 直接上传(不使用 oss2 SDK)"""
put_link = put_link_result.get("putLink")
if not put_link:
raise RuntimeError("无 putLink,需要 oss2 库进行 STS 上传。请运行: pip3 install oss2")
ext = Path(file_path).suffix.lower()
content_type = MIME_MAP.get(ext, "application/octet-stream")
file_size = Path(file_path).stat().st_size
print(f" 正在上传 (PUT 直传)...")
with open(file_path, "rb") as f:
resp = requests.put(
put_link,
data=f,
headers={"Content-Type": content_type},
timeout=600,
)
resp.raise_for_status()
#!/usr/bin/env python3
"""tingwu-asr CLI 入口 — 上传音频/视频到通义听悟进行云端转录"""
import argparse
import json
import subprocess
import sys
from pathlib import Path
from concurrent.futures import ThreadPoolExecutor, as_completed
SKILL_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(Path(__file__).resolve().parent))
from tingwu import TingwuClient, LANG_MAP, VIDEO_EXTS
from format_output import result_to_markdown, lab_to_markdown, save_archive
AUDIO_EXTS = {".mp3", ".wav", ".m4a", ".wma", ".aac", ".ogg", ".amr", ".flac", ".aiff",
".mp4", ".wmv", ".m4v", ".flv", ".rmvb", ".dat", ".mov", ".mkv", ".webm", ".avi",
".mpeg", ".3gp"}
def _load_task_history():
"""加载 completed_tasks.json + pending_tasks.json,用于去重检查"""
history = []
for fname in ("completed_tasks.json", "pending_tasks.json"):
fpath = SKILL_ROOT / "config" / fname
if fpath.exists():
try:
data = json.loads(fpath.read_text(encoding="utf-8"))
if isinstance(data, list):
history.extend(data)
except (json.JSONDecodeError, OSError):
pass
return history
def _find_duplicate(file_path, history):
"""检查文件是否已有转录记录(已完成或进行中)。
返回最近一条匹配记录,不检查本地输出文件是否存在。
"""
resolved = str(file_path.resolve())
for task in reversed(history):
if task.get("file_path") != resolved:
continue
status = task.get("status")
if status in ("completed", "pending", "transcribing"):
return task
return None
def main():
parser = argparse.ArgumentParser(description="通义听悟云端语音转写")
parser.add_argument("paths", nargs="*", help="音频/视频文件路径(支持多个文件并行转录)")
parser.add_argument("-o", "--output", help="输出 Markdown 文件路径(单文件模式)")
parser.add_argument("--lang", default="cn", help="语言: cn/en/ja/cant/cn_en (默认: cn)")
parser.add_argument("--speakers", type=int, default=2,
help="说话人数: 0=不区分, 1=单人, 2=两人(默认), 4=多人")
parser.add_argument("--batch", action="store_true", help="批量转录目录下所有音视频文件")
parser.add_argument("--check-auth", action="store_true", help="检查登录状态")
parser.add_argument("--cookie", help="Cookie 文件路径 (默认: config/cookies.json)")
parser.add_argument("--poll-interval", type=int, default=10, help="轮询间隔秒数 (默认: 10)")
parser.add_argument("--poll-timeout", type=int, default=3600, help="轮询超时秒数 (默认: 3600)")
parser.add_argument("--no-archive", action="store_true", help="不保存归档")
parser.add_argument("--no-lab", action="store_true", help="不获取智能分析(关键词/议程/重点等)")
parser.add_argument("--no-summary", action="store_true", help="不自动生成 AI 总结")
parser.add_argument("--ppt", action="store_true", help="下载 PPT 幻灯片图片并嵌入 Markdown(仅视频有效)")
parser.add_argument("--async", action="store_true", help="异步模式:上传后立即返回,用 poll_tasks.py 查询结果")
parser.add_argument("--json", action="store_true", help="输出原始 JSON 结果")
parser.add_argument("--parallel", type=int, default=3, help="并行转录的最大文件数 (默认: 3)")
parser.add_argument("--force", action="store_true", help="强制重新上传,即使已有转录结果")
args = parser.parse_args()
try:
client = TingwuClient(cookie_path=args.cookie)
except FileNotFoundError as e:
print(f"错误: {e}")
sys.exit(1)
if args.check_auth:
auth = client.check_auth()
if auth["valid"]:
print("登录状态: 有效")
print(json.dumps(auth["user"], ensure_ascii=False, indent=2))
else:
print(f"登录状态: 无效 — {auth['error']}")
print("请运行: python3 scripts/login.py")
sys.exit(1)
return
if not args.paths:
parser.print_help()
sys.exit(1)
lang = LANG_MAP.get(args.lang, args.lang)
# 收集所有文件
all_files = []
for path_str in args.paths:
target = Path(path_str)
if args.batch and target.is_dir():
files = [f for f in sorted(target.iterdir())
if f.suffix.lower() in AUDIO_EXTS]
all_files.extend(files)
else:
if not target.exists():
print(f"文件不存在: {target}")
continue
all_files.append(target)
if not all_files:
print("没有找到音视频文件")
sys.exit(1)
# 去重检查:跳过已转录或正在转录的文件;本地输出缺失时从云端重新下载
if not args.force:
history = _load_task_history()
deduped = []
redownload = []
for f in all_files:
dup = _find_duplicate(f, history)
if not dup:
deduped.append(f)
continue
dup_status = dup.get("status")
if dup_status in ("pending", "transcribing"):
tid = dup.get("trans_id", "?")
print(f" 跳过 {f.name} — 正在转录中 (任务 {tid})")
elif dup_status == "completed":
local_output = dup.get("result", {}).get("output_path")
if local_output and Path(local_output).exists():
prev_spk = dup.get("role_split_num", "?")
print(f" 跳过 {f.name} — 已有转录结果")
if prev_spk != args.speakers:
print(f" (上次说话人={prev_spk}, 本次={args.speakers} — 如需重转请加 --force)")
else:
print(f" 输出: {local_output}")
else:
redownload.append((f, dup))
# 从云端重新下载(不重新上传)
for f, task in redownload:
prev_spk = task.get("role_split_num", "?")
print(f" {f.name} — 云端已有结果,直接下载...")
if prev_spk != args.speakers:
print(f" (云端说话人={prev_spk}, 本次={args.speakers},如需新参数请加 --force)")
try:
_transcribe_one(client, f, args, lang, existing_task=task)
except Exception as e:
print(f" 云端下载失败 ({e}),将重新上传")
deduped.append(f)
skipped = len(all_files) - len(deduped) - len(redownload)
if skipped > 0 or redownload:
print(f"\n去重: 跳过 {skipped} 个,云端下载 {len(redownload)} 个,剩余 {len(deduped)} 个待上传")
if not deduped and not redownload:
print("所有文件均已处理完成,无需重复操作。")
return
all_files = deduped
if not all_files:
return
# 异步模式不支持并行
if getattr(args, 'async'):
for file_path in all_files:
_submit_async(client, file_path, args, lang)
return
# 并行转录
if len(all_files) > 1:
print(f"找到 {len(all_files)} 个文件,开始并行转录(最大并发数: {args.parallel})...")
_transcribe_parallel(client, all_files, args, lang)
else:
_transcribe_one(client, all_files[0], args, lang)
def _submit_async(client, file_path, args, lang):
"""异步模式:上传并提交转录,保存任务到 pending_tasks.json"""
from datetime import datetime
task = client.submit_transcribe(file_path, lang=lang, role_split_num=args.speakers)
pending_path = SKILL_ROOT / "config" / "pending_tasks.json"
if pending_path.exists():
tasks = json.loads(pending_path.read_text(encoding="utf-8"))
else:
tasks = []
entry = {
"trans_id": task["trans_id"],
"file_path": str(file_path.resolve()),
"file_name": task["file_name"],
"lang": task["lang"],
"role_split_num": task["role_split_num"],
"output_path": str(file_path.with_suffix(".md").resolve()),
"ppt": args.ppt or file_path.suffix.lower() in VIDEO_EXTS,
"no_lab": args.no_lab,
"no_archive": args.no_archive,
"no_summary": args.no_summary,
"submitted_at": datetime.now().isoformat(),
"status": "pending",
}
tasks.append(entry)
pending_path.write_text(json.dumps(tasks, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"\n异步任务已提交: {task['trans_id']}")
print(f"任务已保存到: {pending_path}")
print(f"查询状态: python3 scripts/poll_tasks.py")
print(f"后台监控: python3 scripts/poll_tasks.py --monitor")
def _transcribe_parallel(client, files, args, lang):
"""并行转录多个文件"""
results = []
def transcribe_single(file_path):
try:
result = _transcribe_one(client, file_path, args, lang, verbose=False)
return {"success": True, "file": file_path, "result": result}
except Exception as e:
return {"success": False, "file": file_path, "error": str(e)}
with ThreadPoolExecutor(max_workers=args.parallel) as executor:
futures = {executor.submit(transcribe_single, f): f for f in files}
for future in as_completed(futures):
result = future.result()
results.append(result)
file_path = result["file"]
if result["success"]:
print(f"\n完成: {file_path.name}")
for out_path in result["result"].get("output_paths", []):
print(f" → {out_path}")
else:
print(f"\n失败: {file_path.name} - {result['error']}")
# 汇总
success_count = sum(1 for r in results if r["success"])
print(f"\n{'='*50}")
print(f"并行转录完成: {success_count}/{len(results)} 成功")
return results
def _transcribe_one(client, file_path, args, lang, verbose=True, existing_task=None):
"""
转录单个文件,结果同时保存到文件所在目录和 archive 目录
Args:
client: TingwuClient 实例
file_path: 文件路径
args: 命令行参数
lang: 语言代码
verbose: 是否打印详细信息
existing_task: 已有的任务记录(从云端重新下载时传入,跳过上传)
Returns:
包含所有输出路径的结果字典
"""
if existing_task:
# 从云端重新下载已有结果,不重新上传
trans_id = existing_task["trans_id"]
speakers = existing_task.get("role_split_num", args.speakers)
if verbose:
print(f"[{file_path.name}] 从云端获取已有转录结果 (任务 {trans_id})...")
trans_result = client.get_trans_result(trans_id)
result = {
"trans_id": trans_id,
"task_info": None,
"result": trans_result,
"duration": trans_result.get("duration"),
"word_count": trans_result.get("wordCount"),
}
else:
speakers = args.speakers
result = client.transcribe(
file_path,
lang=lang,
role_split_num=speakers,
poll_interval=args.poll_interval,
poll_timeout=args.poll_timeout,
)
# JSON 模式下直接输出并返回
if args.json:
print(json.dumps(result, ensure_ascii=False, indent=2))
return {"result": result, "output_paths": []}
trans_result = result["result"]
if verbose:
print(f"[{file_path.name}] 获取 PPT 幻灯片...")
# 获取 PPT 幻灯片(视频自动启用,音频需手动 --ppt)
ppt_slides = None
slides_ext = ".png"
is_video = file_path.suffix.lower() in VIDEO_EXTS
if is_video or args.ppt:
try:
ppt_slides = client.get_ppt_info(result["trans_id"])
if not ppt_slides:
if verbose:
print(f"[{file_path.name}] 未检测到 PPT 幻灯片")
ppt_slides = None
else:
if verbose:
print(f"[{file_path.name}] 找到 {len(ppt_slides)} 张幻灯片")
out_dir = file_path.parent
downloaded = client.download_ppt_images(ppt_slides, out_dir, file_stem=file_path.stem)
slides_subdir = f"{file_path.stem}_slides"
slides_ext = client.compress_slides(out_dir / slides_subdir)
if verbose:
print(f"[{file_path.name}] 已下载 {len(downloaded)} 张幻灯片图片到 {out_dir / slides_subdir}")
except Exception as e:
if verbose:
print(f"[{file_path.name}] 获取 PPT 失败: {e}")
ppt_slides = None
md = result_to_markdown(
trans_result.get("result", "{}"),
file_path.name,
duration=trans_result.get("duration"),
word_count=trans_result.get("wordCount"),
max_speakers=speakers,
ppt_slides=ppt_slides,
slides_dir_name=f"{file_path.stem}_slides" if ppt_slides else "slides",
slides_ext=slides_ext,
)
# 1. 保存到文件所在目录(支持 --output 参数)
if args.output:
out_path = Path(args.output)
else:
out_path = file_path.with_suffix(".md")
out_path.write_text(md, encoding="utf-8")
if verbose:
print(f"[{file_path.name}] 转录完成: {out_path}")
# 2. 获取智能分析并追加
if not args.no_lab:
try:
if verbose:
print(f"[{file_path.name}] 获取智能分析...")
lab_data = client.get_lab_info(result["trans_id"])
lab_md = lab_to_markdown(lab_data)
if lab_md:
md += lab_md
out_path.write_text(md, encoding="utf-8")
if verbose:
print(f"[{file_path.name}] 智能分析已追加到: {out_path}")
except Exception as e:
if verbose:
print(f"[{file_path.name}] 获取智能分析失败: {e}")
# 3. 保存到 archive 目录
output_paths = [str(out_path)]
if not args.no_archive:
archive_root = SKILL_ROOT / "archive"
md_path, archive_dir = save_archive(
file_path, md, result["trans_id"], trans_result, archive_root
)
if verbose:
print(f"[{file_path.name}] 已归档: {archive_dir}")
output_paths.append(str(md_path))
# 4. 自动 AI 总结(除非指定 --no-summary)
if not getattr(args, "no_summary", False):
try:
summary_py = SKILL_ROOT.parent / "funasr-transcribe" / "scripts" / "summary.py"
if summary_py.exists():
if verbose:
print(f"[{file_path.name}] 生成 AI 总结...")
subprocess.run(
[
"python3", str(summary_py), "inject",
str(out_path), str(out_path.with_suffix(".json")),
],
check=False, timeout=120,
)
if verbose:
print(f"[{file_path.name}] AI 总结已生成")
except Exception as e:
if verbose:
print(f"[{file_path.name}] AI 总结失败: {e}")
if verbose:
print(f"[{file_path.name}] 时长: {trans_result.get('duration', 'N/A')}秒 | 字数: {trans_result.get('wordCount', 'N/A')}")
return {"result": result, "output_paths": output_paths}
if __name__ == "__main__":
main()