iaun vor 3 Monaten
Commit
1813add462

+ 27 - 0
.gitignore

@@ -0,0 +1,27 @@
+# Python
+__pycache__/
+*.pyc
+*.pyo
+
+# Test/local video downloads
+*.mp4
+*.har
+
+# Lambda build artifacts
+sp-transcribe-service/lambdas/*.zip
+
+# Terraform
+sp-transcribe-service/terraform/.terraform/
+sp-transcribe-service/terraform/terraform.tfstate
+sp-transcribe-service/terraform/terraform.tfstate.backup
+sp-transcribe-service/terraform/terraform.tfvars
+
+# Backup
+bak/
+
+# IDE
+.vscode/
+.idea/
+.kiro/
+*.swp
+*.swo

+ 84 - 0
README.md

@@ -0,0 +1,84 @@
+# SharePoint Stream 视频下载器
+
+从 SharePoint Online / Microsoft Stream 下载会议录像等视频。
+
+## 原理
+
+SharePoint Stream 使用 DASH 协议播放视频:
+1. 前端通过 `videomanifest` API 获取 MPD manifest(包含视频/音频分段信息)
+2. 认证方式:`x-spopactoken`(PAC token)用于 manifest,`FedAuth` HttpOnly cookie 用于 segment
+3. 每个 segment 使用 AES-128-CBC 加密,密钥从 `VideoProtectionKey` API 获取
+4. 视频和音频分开下载,最后用 ffmpeg 合并
+
+## 依赖
+
+```bash
+pip install requests pycryptodome
+```
+
+合并音视频需要 [ffmpeg](https://ffmpeg.org/download.html)。
+
+## 使用步骤
+
+### 1. 获取 videomanifest cURL
+
+- 打开视频页面,按 F12 打开 DevTools
+- 切到 Network 标签,Filter 输入 `videomanifest`
+- 播放视频,会出现两个同名请求:
+  - `OPTIONS`(预检)— 不要复制这个
+  - `GET`(实际请求)— 复制这个
+- 右键 GET 请求 → Copy → Copy as cURL (bash)
+- 保存到 `curl_command.txt`
+
+### 2. 导出 Cookie
+
+SharePoint 的 `FedAuth`/`rtFa` 是 HttpOnly cookie,`document.cookie` 拿不到。
+
+- 安装浏览器扩展 [Cookie-Editor](https://cookie-editor.com/)
+- 在视频页面点击扩展图标 → Export → JSON
+- 保存为 `cookies.json`
+
+### 3. 下载
+
+```bash
+python sp_video_dl.py curl_command.txt -c cookies.json -o my_video
+```
+
+## 参数
+
+| 参数 | 说明 |
+|------|------|
+| `input` | cURL 命令文件路径,或交互模式粘贴 |
+| `-c, --cookie` | Cookie JSON 文件或字符串 |
+| `-o, --output` | 输出目录(默认 `output`) |
+| `-w, --workers` | 并发线程数(默认 4) |
+| `--dry-run` | 仅解析 manifest,不下载 |
+
+## 常见问题
+
+### 复制了错误的请求(OPTIONS preflight)
+脚本会自动检测并提示。确保复制的是 Method 为 GET 的请求(headers 里有 `x-spopactoken: v1.eyJ...`)。
+
+### 下载到一半 401
+缺少 HttpOnly cookie。用 Cookie-Editor 扩展导出 JSON,通过 `-c cookies.json` 传入。
+
+### Cookie 过期
+SharePoint cookie 有效期通常几小时。过期后重新从浏览器导出。
+
+### ffmpeg 合并失败
+视频和音频文件已分别保存在输出目录,手动合并:
+```bash
+ffmpeg -y -i my_video/video.mp4 -i my_video/audio.mp4 -c copy my_video/final.mp4
+```
+
+## 输出文件
+
+| 文件 | 说明 |
+|------|------|
+| `final.mp4` | 合并后的完整视频 |
+| `video.mp4` | 视频轨道 |
+| `audio.mp4` | 音频轨道 |
+| `transcript.json` | 原始转录数据 (JSON) |
+| `transcript.srt` | SRT 字幕文件 |
+| `transcript.txt` | 纯文本转录 (带说话人和时间戳) |
+| `manifest.mpd` | DASH manifest |

+ 111 - 0
simplify_transcript.py

@@ -0,0 +1,111 @@
+#!/usr/bin/env python3
+"""
+简化 Amazon Transcribe JSON 输出,合并为按说话人分段的文本。
+适合直接喂给 LLM 做会议总结。
+
+用法: python simplify_transcript.py testjob.json [-o output.txt]
+"""
+
+import json
+import sys
+import argparse
+
+
+def load_transcribe(path: str) -> dict:
+    with open(path, 'r', encoding='utf-8') as f:
+        return json.load(f)
+
+
+def simplify(data: dict) -> str:
+    """将 Amazon Transcribe JSON 合并为 Speaker + 文本段落"""
+    items = data['results']['items']
+    
+    # 构建段落: 按说话人变化分段
+    paragraphs = []
+    current_speaker = None
+    current_text = []
+    current_start = None
+    
+    for item in items:
+        speaker = item.get('speaker_label', '')
+        content = item['alternatives'][0]['content']
+        item_type = item['type']  # pronunciation / punctuation
+        
+        # 标点符号不换说话人,直接追加
+        if item_type == 'punctuation':
+            if current_text:
+                current_text.append(content)
+            continue
+        
+        # 说话人变了,保存当前段落
+        if speaker != current_speaker and current_text:
+            paragraphs.append({
+                'speaker': current_speaker,
+                'start': current_start,
+                'text': ''.join(current_text).strip(),
+            })
+            current_text = []
+        
+        if speaker != current_speaker:
+            current_speaker = speaker
+            current_start = item.get('start_time', '')
+        
+        # 中文不加空格,英文/数字前加空格
+        if current_text and not content.startswith((',', '。', '?', '!', '、', ':', ';')):
+            # 判断是否需要空格 (前一个和当前都是 ASCII 才加)
+            prev = current_text[-1] if current_text else ''
+            if prev and prev[-1].isascii() and content[0].isascii():
+                current_text.append(' ')
+        current_text.append(content)
+    
+    # 最后一段
+    if current_text:
+        paragraphs.append({
+            'speaker': current_speaker,
+            'start': current_start,
+            'text': ''.join(current_text).strip(),
+        })
+    
+    # 格式化输出
+    lines = []
+    speaker_count = len(set(p['speaker'] for p in paragraphs))
+    lines.append(f"# 会议转录 ({speaker_count} 位发言人, {len(paragraphs)} 段)")
+    lines.append("")
+    
+    for p in paragraphs:
+        start = p['start']
+        if start:
+            secs = float(start)
+            mins = int(secs // 60)
+            s = int(secs % 60)
+            ts = f"[{mins:02d}:{s:02d}]"
+        else:
+            ts = ""
+        
+        lines.append(f"**{p['speaker']}** {ts}")
+        lines.append(p['text'])
+        lines.append("")
+    
+    return '\n'.join(lines)
+
+
+def main():
+    parser = argparse.ArgumentParser(description="简化 Amazon Transcribe JSON")
+    parser.add_argument("input", help="Amazon Transcribe JSON 文件")
+    parser.add_argument("-o", "--output", help="输出文件 (默认: 同名.txt)")
+    args = parser.parse_args()
+    
+    data = load_transcribe(args.input)
+    result = simplify(data)
+    
+    out_path = args.output or args.input.rsplit('.', 1)[0] + '_simplified.txt'
+    with open(out_path, 'w', encoding='utf-8') as f:
+        f.write(result)
+    
+    print(f"已保存: {out_path}")
+    print(f"大小: {len(result)} 字符 (原始 JSON: {len(open(args.input, encoding='utf-8').read())} 字符)")
+    print(f"压缩比: {len(result) / len(open(args.input, encoding='utf-8').read()) * 100:.1f}%")
+
+
+if __name__ == "__main__":
+    main()

+ 267 - 0
sp-stream-helper.user.js

@@ -0,0 +1,267 @@
+// ==UserScript==
+// @name         SharePoint Stream Helper
+// @namespace    https://github.com/sp-stream-helper
+// @version      1.1
+// @description  一键复制 SharePoint Stream videomanifest 请求为 cURL 格式
+// @match        https://*.sharepoint.com/personal/*/_layouts/*/stream.aspx*
+// @match        https://*.sharepoint.com/sites/*/_layouts/*/stream.aspx*
+// @grant        none
+// @run-at       document-start
+// ==/UserScript==
+
+(function () {
+    'use strict';
+
+    let capturedManifest = null;
+    let btnReady = false;
+
+    // ========================================
+    // 1. 在 document-start 阶段拦截 fetch/XHR
+    //    @grant none 让脚本在页面上下文运行
+    // ========================================
+
+    // --- 拦截 fetch ---
+    const origFetch = window.fetch;
+    window.fetch = function (input, init) {
+        try {
+            const url = typeof input === 'string' ? input : (input?.url || '');
+            const method = (init?.method || input?.method || 'GET').toUpperCase();
+            const headers = extractHeaders(init?.headers || input?.headers);
+            maybeCapture(method, url, headers);
+        } catch (e) { /* 不影响原始请求 */ }
+        return origFetch.apply(this, arguments);
+    };
+
+    // --- 拦截 XMLHttpRequest ---
+    const origOpen = XMLHttpRequest.prototype.open;
+    const origSetHeader = XMLHttpRequest.prototype.setRequestHeader;
+    const origSend = XMLHttpRequest.prototype.send;
+
+    XMLHttpRequest.prototype.open = function (method, url) {
+        this._spH = { method: (method || 'GET').toUpperCase(), url: url || '', headers: {} };
+        return origOpen.apply(this, arguments);
+    };
+
+    XMLHttpRequest.prototype.setRequestHeader = function (name, value) {
+        if (this._spH) this._spH.headers[name] = value;
+        return origSetHeader.apply(this, arguments);
+    };
+
+    XMLHttpRequest.prototype.send = function () {
+        if (this._spH) maybeCapture(this._spH.method, this._spH.url, this._spH.headers);
+        return origSend.apply(this, arguments);
+    };
+
+    // ========================================
+    // 2. 辅助函数
+    // ========================================
+    function extractHeaders(raw) {
+        const h = {};
+        if (!raw) return h;
+        if (raw instanceof Headers) {
+            raw.forEach((v, k) => { h[k] = v; });
+        } else if (Array.isArray(raw)) {
+            raw.forEach(([k, v]) => { h[k] = v; });
+        } else if (typeof raw === 'object') {
+            Object.keys(raw).forEach(k => { h[k] = raw[k]; });
+        }
+        return h;
+    }
+
+    function maybeCapture(method, url, headers) {
+        if (!url || !url.includes('videomanifest')) return;
+        if (method === 'OPTIONS') return;
+
+        // 必须有 pac token
+        const pacKey = Object.keys(headers).find(k => k.toLowerCase() === 'x-spopactoken');
+        if (!pacKey) return;
+
+        capturedManifest = { method, url, headers: { ...headers }, time: new Date().toLocaleTimeString() };
+        console.log('[SP Helper] ✅ 捕获 videomanifest!', url.substring(0, 80) + '...');
+        if (btnReady) updateButton();
+    }
+
+    // ========================================
+    // 3. 备用方案: PerformanceObserver 监听
+    //    如果 fetch/XHR 拦截失败,从 performance entries 拿 URL
+    //    再从 g_fileInfo 拿 PAC token 拼 cURL
+    // ========================================
+    function startPerfObserver() {
+        try {
+            const obs = new PerformanceObserver((list) => {
+                for (const entry of list.getEntries()) {
+                    if (entry.name && entry.name.includes('videomanifest') && !entry.name.includes('OPTIONS')) {
+                        if (!capturedManifest) {
+                            // 从 g_fileInfo 获取 PAC token
+                            const pacToken = getPacToken();
+                            if (pacToken) {
+                                capturedManifest = {
+                                    method: 'GET',
+                                    url: entry.name,
+                                    headers: { 'x-spopactoken': pacToken },
+                                    time: new Date().toLocaleTimeString(),
+                                    source: 'PerformanceObserver',
+                                };
+                                console.log('[SP Helper] ✅ 通过 PerformanceObserver 捕获!');
+                                if (btnReady) updateButton();
+                            }
+                        }
+                    }
+                }
+            });
+            obs.observe({ type: 'resource', buffered: true });
+        } catch (e) {
+            console.log('[SP Helper] PerformanceObserver 不可用:', e.message);
+        }
+    }
+
+    function getPacToken() {
+        // 方法1: g_fileInfo
+        try {
+            if (window.g_fileInfo) {
+                const code = window.g_fileInfo['.driveAccessCode'] ||
+                             window.g_fileInfo['.driveAccessCodeV21'];
+                if (code) return code;
+            }
+        } catch (e) {}
+
+        // 方法2: 从页面 HTML 中提取
+        try {
+            const html = document.documentElement.innerHTML;
+            const m = html.match(/["']\.driveAccessCode["']\s*:\s*["']([^"']+)["']/);
+            if (m) return m[1];
+        } catch (e) {}
+
+        return null;
+    }
+
+    // ========================================
+    // 4. 生成 cURL
+    // ========================================
+    function toCurl(req) {
+        const parts = [`curl '${req.url}'`];
+
+        // 从捕获的 headers 中提取 pac token 和 origin/referer
+        let pacToken = '';
+        let origin = '';
+        let referer = '';
+        for (const [name, value] of Object.entries(req.headers)) {
+            const lower = name.toLowerCase();
+            if (lower === 'x-spopactoken') pacToken = value;
+            if (lower === 'origin') origin = value;
+            if (lower === 'referer') referer = value;
+        }
+
+        // 固定 headers (模拟浏览器 Chrome 146)
+        parts.push(`  -H 'accept: */*'`);
+        parts.push(`  -H 'accept-language: zh-CN,zh;q=0.9'`);
+        if (origin) parts.push(`  -H 'origin: ${origin}'`);
+        parts.push(`  -H 'priority: u=1, i'`);
+        if (referer) parts.push(`  -H 'referer: ${referer}'`);
+        parts.push(`  -H 'sec-ch-ua: "Chromium";v="146", "Not-A.Brand";v="24", "Google Chrome";v="146"'`);
+        parts.push(`  -H 'sec-ch-ua-mobile: ?0'`);
+        parts.push(`  -H 'sec-ch-ua-platform: "Windows"'`);
+        parts.push(`  -H 'sec-fetch-dest: empty'`);
+        parts.push(`  -H 'sec-fetch-mode: cors'`);
+        parts.push(`  -H 'sec-fetch-site: cross-site'`);
+        parts.push(`  -H 'user-agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36'`);
+        if (pacToken) {
+            const safeVal = pacToken.replace(/'/g, "'\\''");
+            parts.push(`  -H 'x-spopactoken: ${safeVal}'`);
+        }
+
+        return parts.join(' \\\n');
+    }
+
+    // ========================================
+    // 5. UI
+    // ========================================
+    function createButton() {
+        const btn = document.createElement('div');
+        btn.id = 'sp-helper-btn';
+        btn.textContent = '⏳ 等待视频播放...';
+        Object.assign(btn.style, {
+            position: 'fixed',
+            bottom: '20px',
+            right: '20px',
+            zIndex: '2147483647',
+            padding: '10px 18px',
+            borderRadius: '8px',
+            background: '#555',
+            color: '#ccc',
+            fontSize: '14px',
+            fontFamily: 'Consolas, "Courier New", monospace',
+            cursor: 'not-allowed',
+            boxShadow: '0 2px 12px rgba(0,0,0,0.4)',
+            transition: 'all 0.3s',
+            userSelect: 'none',
+        });
+        btn.addEventListener('click', onCopy);
+        document.body.appendChild(btn);
+        btnReady = true;
+        return btn;
+    }
+
+    function updateButton() {
+        let btn = document.getElementById('sp-helper-btn');
+        if (!btn) btn = createButton();
+        if (capturedManifest) {
+            const src = capturedManifest.source ? ` [${capturedManifest.source}]` : '';
+            btn.innerHTML = `📋 复制 cURL <span style="font-size:11px;opacity:0.6">${capturedManifest.time}${src}</span>`;
+            btn.style.background = '#0078d4';
+            btn.style.color = '#fff';
+            btn.style.cursor = 'pointer';
+        }
+    }
+
+    function onCopy() {
+        if (!capturedManifest) return;
+        const curl = toCurl(capturedManifest);
+
+        navigator.clipboard.writeText(curl).then(() => {
+            showCopied();
+        }).catch(() => {
+            // fallback
+            const ta = document.createElement('textarea');
+            ta.value = curl;
+            ta.style.cssText = 'position:fixed;left:-9999px';
+            document.body.appendChild(ta);
+            ta.select();
+            document.execCommand('copy');
+            ta.remove();
+            showCopied();
+        });
+
+        console.log('[SP Helper] cURL 已复制 (' + curl.length + ' chars)');
+    }
+
+    function showCopied() {
+        const btn = document.getElementById('sp-helper-btn');
+        if (!btn) return;
+        const prev = btn.innerHTML;
+        btn.innerHTML = '✅ 已复制!';
+        btn.style.background = '#107c10';
+        setTimeout(() => {
+            btn.innerHTML = prev;
+            btn.style.background = '#0078d4';
+        }, 2000);
+    }
+
+    // ========================================
+    // 6. 启动
+    // ========================================
+    // DOM ready 后创建按钮和启动 PerformanceObserver
+    if (document.readyState === 'loading') {
+        document.addEventListener('DOMContentLoaded', init);
+    } else {
+        init();
+    }
+
+    function init() {
+        createButton();
+        startPerfObserver();
+        // 如果已经捕获到了(fetch/XHR 拦截在 document-start 就生效)
+        if (capturedManifest) updateButton();
+        console.log('[SP Helper] 已加载,等待 videomanifest 请求...');
+    }
+})();

+ 80 - 0
sp-transcribe-service/README.md

@@ -0,0 +1,80 @@
+# SharePoint Stream Transcribe Service
+
+自动下载 SharePoint Stream 视频音频 → Amazon Transcribe 转录 → LLM 总结 → 邮件发送。
+
+## 架构
+
+```
+用户 (Tampermonkey)
+  │
+  ├─ POST /submit  (cURL + cookies + API Key)
+  │
+  ▼
+API Gateway ──► Lambda (submit)
+                  │  验证 API Key, 存任务到 DynamoDB
+                  │  启动 Step Functions
+                  ▼
+              Step Functions
+                  │
+                  ├─ 1. Lambda (download) ── 下载音频 → S3
+                  ├─ 2. Lambda (transcribe) ── 提交 Transcribe 任务
+                  ├─ 3. Wait + Lambda (check) ── 轮询 Transcribe 状态
+                  ├─ 4. Lambda (summarize) ── 调用 LLM 总结
+                  └─ 5. Lambda (notify) ── SMTP 发送邮件
+                  
+DynamoDB (users)  ── API Key + Email + 配置
+DynamoDB (jobs)   ── 任务状态追踪
+S3 (media)        ── 音频文件 + 转录结果
+SSM Parameters    ── SMTP/LLM 密钥等敏感配置
+```
+
+## 部署
+
+```bash
+# 1. 进入项目目录
+cd sp-transcribe-service
+
+# 2. 打包 Lambda 函数
+bash scripts/build_lambdas.sh
+
+# 3. 配置 Terraform
+cp terraform/terraform.tfvars.example terraform/terraform.tfvars
+# 编辑 terraform/terraform.tfvars 填入 SMTP/LLM 配置
+
+# 4. 部署
+cd terraform
+terraform init
+terraform apply
+```
+
+## 管理用户
+
+```bash
+# 添加用户
+python scripts/manage_users.py add --email user@example.com --name "User Name"
+
+# 列出用户
+python scripts/manage_users.py list
+
+# 删除用户
+python scripts/manage_users.py delete --api-key <key>
+```
+
+## Tampermonkey 插件
+
+安装 `userscript/sp-stream-helper.user.js`,在设置面板填入 API Endpoint 和 API Key。
+
+## 配置
+
+通过 Terraform variables 或 SSM Parameter Store 配置:
+
+| 配置项 | 说明 |
+|--------|------|
+| `smtp_host` | SMTP 服务器地址 |
+| `smtp_port` | SMTP 端口 (587) |
+| `smtp_user` | SMTP 用户名 |
+| `smtp_pass` | SMTP 密码 |
+| `smtp_from` | 发件人地址 |
+| `llm_api_url` | OpenAI 兼容 LLM API 地址 |
+| `llm_api_key` | LLM API Key |
+| `llm_model` | 模型名称 |

+ 235 - 0
sp-transcribe-service/lambdas/download.py

@@ -0,0 +1,235 @@
+"""Download audio from SharePoint Stream and upload to S3."""
+import json
+import re
+import os
+import time
+import boto3
+from urllib.parse import unquote, urlparse, parse_qs
+from xml.etree import ElementTree as ET
+from shared import S3_BUCKET, update_job
+
+MAX_RETRIES = 5
+RETRY_BACKOFF = [1, 2, 4, 8, 16]  # seconds
+
+s3 = boto3.client('s3')
+
+NS = {
+    'mpd': 'urn:mpeg:DASH:schema:MPD:2011',
+    'sea': 'urn:mpeg:dash:schema:sea:2012',
+}
+
+
+def handler(event, context):
+    job_id = event['job_id']
+    update_job(job_id, status='DOWNLOADING')
+
+    # Load input
+    obj = s3.get_object(Bucket=S3_BUCKET, Key=f"jobs/{job_id}/input.json")
+    inp = json.loads(obj['Body'].read())
+    curl_cmd = inp['curl']
+    cookies_str = inp.get('cookies', '')
+    language = inp.get('language', 'auto')
+
+    # Parse cURL
+    manifest_url, headers = parse_curl(curl_cmd)
+
+    # Setup session
+    import requests
+    session = requests.Session()
+    session.headers.update({
+        'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
+    })
+
+    # Set cookies
+    if cookies_str:
+        sp_domain = extract_sp_domain(manifest_url)
+        cookies_str = cookies_str.strip()
+        # 支持 Cookie-Editor JSON 数组格式
+        if cookies_str.startswith('['):
+            try:
+                cookie_list = json.loads(cookies_str)
+                for c in cookie_list:
+                    name = c.get('name', '')
+                    value = c.get('value', '')
+                    domain = c.get('domain', sp_domain)
+                    if domain.startswith('.'):
+                        domain = domain[1:]
+                    if name and value:
+                        session.cookies.set(name, value, domain=domain)
+            except json.JSONDecodeError:
+                pass
+        else:
+            # name=value; name=value 格式
+            for pair in cookies_str.split('; '):
+                if '=' in pair:
+                    name, value = pair.split('=', 1)
+                    session.cookies.set(name.strip(), value.strip(), domain=sp_domain)
+
+    # Fetch manifest
+    svc_headers = {}
+    for k, v in headers.items():
+        if k.lower() in ('x-spopactoken', 'origin', 'referer'):
+            svc_headers[k] = v
+
+    resp = fetch_with_retry(session, manifest_url, svc_headers)
+    manifest_xml = resp.text
+
+    # Parse manifest - find audio track
+    root = ET.fromstring(manifest_xml)
+    base_url_el = root.find('mpd:BaseURL', NS)
+    base_url = base_url_el.text.strip() if base_url_el is not None else ''
+
+    period = root.find('mpd:Period', NS)
+    audio_track = None
+    for adapt in period.findall('mpd:AdaptationSet', NS):
+        if adapt.get('contentType') == 'audio':
+            label_el = adapt.find('mpd:Label', NS)
+            label = label_el.text if label_el is not None else ''
+            if label == 'OriginalAudio' or audio_track is None:
+                audio_track = adapt
+            if label == 'OriginalAudio':
+                break
+
+    if audio_track is None:
+        raise Exception("No audio track found in manifest")
+
+    # Get encryption key
+    cp = audio_track.find('mpd:ContentProtection', NS)
+    enc_key = None
+    enc_iv = None
+    if cp is not None:
+        crypto = cp.find('sea:CryptoPeriod', NS)
+        if crypto is not None:
+            key_url = crypto.get('keyUriTemplate', '').replace('&amp;', '&')
+            iv_str = crypto.get('IV', '')
+            if key_url:
+                kr = fetch_with_retry(session, key_url, svc_headers)
+                enc_key = kr.content
+            if iv_str.startswith('0x'):
+                iv_str = iv_str[2:]
+            enc_iv = bytes.fromhex(iv_str) if iv_str else None
+
+    # Parse segments
+    seg_tpl = audio_track.find('mpd:SegmentTemplate', NS)
+    init_tpl = seg_tpl.get('initialization', '').replace('&amp;', '&')
+    media_tpl = seg_tpl.get('media', '').replace('&amp;', '&')
+    rep = audio_track.find('mpd:Representation', NS)
+    rep_id = rep.get('id', '')
+
+    segments = []
+    timeline = seg_tpl.find('mpd:SegmentTimeline', NS)
+    t = 0
+    for s_el in timeline.findall('mpd:S', NS):
+        d = int(s_el.get('d', 0))
+        r = int(s_el.get('r', 0))
+        for _ in range(r + 1):
+            segments.append(t)
+            t += d
+
+    # Download init + segments
+    sp_headers = {}
+    for k, v in headers.items():
+        if k.lower() in ('origin', 'referer'):
+            sp_headers[k] = v
+
+    audio_data = bytearray()
+
+    # Init segment
+    init_url = resolve_url(base_url, init_tpl, rep_id)
+    r = fetch_with_retry(session, init_url, sp_headers)
+    audio_data.extend(decrypt(r.content, enc_key, enc_iv))
+
+    # Media segments
+    total = len(segments)
+    for idx, seg_time in enumerate(segments):
+        url = resolve_url(base_url, media_tpl, rep_id, seg_time)
+        r = fetch_with_retry(session, url, sp_headers)
+        audio_data.extend(decrypt(r.content, enc_key, enc_iv))
+        if (idx + 1) % 50 == 0:
+            print(f"[download] progress: {idx+1}/{total} segments")
+
+    # Upload to S3
+    s3_key = f"jobs/{job_id}/audio.mp4"
+    s3.put_object(Bucket=S3_BUCKET, Key=s3_key, Body=bytes(audio_data), ContentType='audio/mp4')
+
+    update_job(job_id, status='DOWNLOADED', audio_s3_key=s3_key)
+    return {**event, 'audio_s3_key': s3_key, 'language': language}
+
+
+def fetch_with_retry(session, url, headers):
+    """GET with retry on 5xx / connection errors."""
+    import requests
+    for attempt in range(MAX_RETRIES):
+        try:
+            r = session.get(url, headers=headers, timeout=60)
+            if r.status_code < 500:
+                r.raise_for_status()
+                return r
+            # 5xx: retry
+            print(f"[download] HTTP {r.status_code}, retry {attempt+1}/{MAX_RETRIES}")
+        except (requests.ConnectionError, requests.Timeout) as e:
+            print(f"[download] {type(e).__name__}, retry {attempt+1}/{MAX_RETRIES}")
+        if attempt < MAX_RETRIES - 1:
+            time.sleep(RETRY_BACKOFF[attempt])
+    # Last attempt — let it raise
+    r = session.get(url, headers=headers, timeout=60)
+    r.raise_for_status()
+    return r
+
+
+def parse_curl(curl_str):
+    import shlex
+    curl_str = curl_str.replace('\\\n', ' ').replace('\\\r\n', ' ')
+    tokens = shlex.split(curl_str, posix=True)
+    url = None
+    headers = {}
+    i = 0
+    while i < len(tokens):
+        t = tokens[i]
+        if t in ('-H', '--header') and i + 1 < len(tokens):
+            i += 1
+            hdr = tokens[i]
+            if ':' in hdr:
+                k, v = hdr.split(':', 1)
+                headers[k.strip()] = v.strip()
+        elif t.startswith('http'):
+            url = t
+        elif t in ('-X', '--request') and i + 1 < len(tokens):
+            i += 1
+        i += 1
+    if not url:
+        m = re.search(r"(https?://[^\s'\"]+)", curl_str)
+        if m:
+            url = m.group(1)
+    return url, headers
+
+
+def extract_sp_domain(manifest_url):
+    qs = parse_qs(urlparse(manifest_url).query)
+    docid = unquote(qs.get('docid', [''])[0])
+    if docid:
+        return urlparse(docid).hostname
+    return ''
+
+
+def resolve_url(base_url, template, rep_id, seg_time=None):
+    url = template
+    url = re.sub(r'\$RepresentationID[^&]*amp;', rep_id, url)
+    url = re.sub(r'\$RepresentationID\$', rep_id, url)
+    if seg_time is not None:
+        url = re.sub(r'\$Time[^&]*amp;', str(seg_time), url)
+        url = re.sub(r'\$Time\$', str(seg_time), url)
+    return base_url + url
+
+
+def decrypt(data, key, iv):
+    if not key:
+        return data
+    from Crypto.Cipher import AES
+    cipher = AES.new(key, AES.MODE_CBC, iv)
+    dec = cipher.decrypt(data)
+    if len(dec) > 0:
+        pad = dec[-1]
+        if 0 < pad <= 16 and dec[-pad:] == bytes([pad]) * pad:
+            dec = dec[:-pad]
+    return dec

+ 67 - 0
sp-transcribe-service/lambdas/notify.py

@@ -0,0 +1,67 @@
+"""Send email notification via SMTP."""
+import json
+import smtplib
+from email.mime.text import MIMEText
+from email.mime.multipart import MIMEMultipart
+from shared import get_ssm, update_job
+
+
+def handler(event, context):
+    # Step Functions Catch 会把原始 input 包在 error 结构里
+    # 正常流程: event 有 job_id, email, summary
+    # 错误流程: event 有 job_id, email + error_info.Error/Cause
+    job_id = event.get('job_id', 'unknown')
+    email = event.get('email', '')
+    user_name = event.get('user_name', '')
+    summary = event.get('summary', '')
+    error_info = event.get('error_info', {})
+    error = error_info.get('Error', '') if isinstance(error_info, dict) else ''
+    cause = error_info.get('Cause', '') if isinstance(error_info, dict) else ''
+
+    if error or cause:
+        subject = f"[SP Transcribe] 任务 {job_id} 失败"
+        body = f"任务 {job_id} 处理失败。\n\n错误: {error}\n详情: {cause[:500]}"
+        if job_id != 'unknown':
+            update_job(job_id, status='FAILED', error=str(error)[:500])
+    else:
+        subject = f"[SP Transcribe] 会议纪要 - {job_id}"
+        body = summary or '(无总结内容)'
+        update_job(job_id, status='COMPLETED')
+
+    if not email:
+        return {**event, 'notify': 'skipped (no email)'}
+
+    # Send via SMTP
+    smtp_host = get_ssm('smtp/host')
+    smtp_port = int(get_ssm('smtp/port'))
+    smtp_user = get_ssm('smtp/user')
+    smtp_pass = get_ssm('smtp/pass')
+    smtp_from = get_ssm('smtp/from')
+
+    msg = MIMEMultipart('alternative')
+    msg['Subject'] = subject
+    msg['From'] = smtp_from
+    msg['To'] = email
+
+    # Plain text
+    msg.attach(MIMEText(body, 'plain', 'utf-8'))
+
+    # HTML (markdown-like)
+    html_body = body.replace('\n', '<br>').replace('**', '<b>').replace('</b><b>', '')
+    msg.attach(MIMEText(f"<html><body style='font-family:sans-serif'>{html_body}</body></html>", 'html', 'utf-8'))
+
+    with _smtp_connect(smtp_host, smtp_port, smtp_user, smtp_pass) as server:
+        server.send_message(msg)
+
+    return {**event, 'notify': 'sent'}
+
+
+def _smtp_connect(host, port, user, passwd):
+    """465 用 SMTP_SSL (隐式 TLS), 587 用 SMTP + STARTTLS"""
+    if port == 465:
+        server = smtplib.SMTP_SSL(host, port)
+    else:
+        server = smtplib.SMTP(host, port)
+        server.starttls()
+    server.login(user, passwd)
+    return server

+ 53 - 0
sp-transcribe-service/lambdas/shared.py

@@ -0,0 +1,53 @@
+"""Shared utilities for all Lambda functions."""
+import os
+import json
+import time
+import boto3
+
+PROJECT = os.environ.get('PROJECT', 'sp-transcribe')
+S3_BUCKET = os.environ.get('S3_BUCKET', '')
+USERS_TABLE = os.environ.get('USERS_TABLE', '')
+JOBS_TABLE = os.environ.get('JOBS_TABLE', '')
+SSM_PREFIX = os.environ.get('SSM_PREFIX', '/sp-transcribe')
+
+_ssm_cache = {}
+
+def get_ssm(name: str) -> str:
+    if name in _ssm_cache:
+        return _ssm_cache[name]
+    ssm = boto3.client('ssm')
+    resp = ssm.get_parameter(Name=f"{SSM_PREFIX}/{name}", WithDecryption=True)
+    val = resp['Parameter']['Value']
+    _ssm_cache[name] = val
+    return val
+
+def get_user(api_key: str) -> dict | None:
+    ddb = boto3.resource('dynamodb')
+    table = ddb.Table(USERS_TABLE)
+    resp = table.get_item(Key={'api_key': api_key})
+    return resp.get('Item')
+
+def update_job(job_id: str, **kwargs):
+    ddb = boto3.resource('dynamodb')
+    table = ddb.Table(JOBS_TABLE)
+    expr_parts = []
+    values = {}
+    names = {}
+    for k, v in kwargs.items():
+        safe = k.replace('.', '_')
+        expr_parts.append(f"#{safe} = :{safe}")
+        values[f":{safe}"] = v
+        names[f"#{safe}"] = k
+    table.update_item(
+        Key={'job_id': job_id},
+        UpdateExpression="SET " + ", ".join(expr_parts),
+        ExpressionAttributeValues=values,
+        ExpressionAttributeNames=names,
+    )
+
+def api_response(status: int, body: dict) -> dict:
+    return {
+        'statusCode': status,
+        'headers': {'Content-Type': 'application/json'},
+        'body': json.dumps(body),
+    }

+ 77 - 0
sp-transcribe-service/lambdas/submit.py

@@ -0,0 +1,77 @@
+"""API Gateway handler: validate API key, create job, start Step Functions."""
+import json
+import uuid
+import time
+import os
+import boto3
+from shared import get_user, api_response, JOBS_TABLE, S3_BUCKET
+
+sfn = boto3.client('stepfunctions')
+ddb = boto3.resource('dynamodb')
+
+
+def handler(event, context):
+    # Parse body
+    try:
+        body = json.loads(event.get('body', '{}'))
+    except Exception:
+        return api_response(400, {'error': 'Invalid JSON body'})
+
+    api_key = body.get('api_key') or event.get('headers', {}).get('x-api-key', '')
+    curl_command = body.get('curl')
+    cookies = body.get('cookies', '')
+
+    if not api_key:
+        return api_response(401, {'error': 'Missing api_key'})
+    if not curl_command:
+        return api_response(400, {'error': 'Missing curl'})
+
+    # Validate user
+    user = get_user(api_key)
+    if not user:
+        return api_response(401, {'error': 'Invalid API key'})
+
+    # Create job
+    job_id = str(uuid.uuid4())[:8]
+    now = int(time.time())
+    ttl = now + 7 * 86400  # 7 days
+
+    table = ddb.Table(JOBS_TABLE)
+    table.put_item(Item={
+        'job_id': job_id,
+        'api_key': api_key,
+        'email': user.get('email', ''),
+        'status': 'SUBMITTED',
+        'created_at': now,
+        'ttl': ttl,
+    })
+
+    # Store curl + cookies in S3 (avoid DynamoDB size limits)
+    s3 = boto3.client('s3')
+    s3.put_object(
+        Bucket=S3_BUCKET,
+        Key=f"jobs/{job_id}/input.json",
+        Body=json.dumps({
+            'curl': curl_command,
+            'cookies': cookies,
+            'language': body.get('language', 'auto'),
+        }),
+        ContentType='application/json',
+    )
+
+    # Start Step Functions
+    state_machine_arn = os.environ.get('STATE_MACHINE', '')
+    sfn.start_execution(
+        stateMachineArn=state_machine_arn,
+        name=f"job-{job_id}",
+        input=json.dumps({
+            'job_id': job_id,
+            'email': user.get('email', ''),
+            'user_name': user.get('name', ''),
+        }),
+    )
+
+    return api_response(200, {
+        'job_id': job_id,
+        'message': 'Job submitted, you will receive an email when complete.',
+    })

+ 160 - 0
sp-transcribe-service/lambdas/summarize.py

@@ -0,0 +1,160 @@
+"""Simplify transcript and call LLM for summary with token-aware truncation."""
+import json
+import boto3
+import tiktoken
+from openai import OpenAI
+from shared import S3_BUCKET, get_ssm, update_job
+
+s3 = boto3.client('s3')
+
+# 模型上下文窗口大小 (input tokens)
+# 保守估计,预留 output tokens 和 system prompt
+MODEL_CONTEXT = {
+    'default': 128000,  # 输入 128k,输出 16k,分开计算
+}
+SYSTEM_PROMPT = '你是一个专业的会议纪要助手。'
+USER_PROMPT_TEMPLATE = """请根据以下会议转录内容,使用简体中文普通话,生成一份结构化的会议纪要,包含:
+1. 会议主题
+2. 参与人员(用 Speaker 标识)
+3. 关键讨论点
+4. 决议事项和 Action Items
+5. 时间安排
+
+转录内容:
+{transcript}"""
+
+# 初始化 tokenizer (cl100k_base 适用于 GPT-4/Claude/大多数中文模型)
+try:
+    _enc = tiktoken.get_encoding('cl100k_base')
+except Exception:
+    _enc = None
+
+
+def count_tokens(text: str) -> int:
+    """计算 token 数"""
+    if _enc:
+        return len(_enc.encode(text))
+    # fallback: 中文约 1.5 token/字, 英文约 1.3 token/word
+    return int(len(text) * 0.7)
+
+
+def truncate_to_tokens(text: str, max_tokens: int) -> tuple[str, int]:
+    """截断文本到指定 token 数,返回 (截断后文本, 实际 token 数)"""
+    if _enc:
+        tokens = _enc.encode(text)
+        if len(tokens) <= max_tokens:
+            return text, len(tokens)
+        truncated = _enc.decode(tokens[:max_tokens])
+        return truncated, max_tokens
+    # fallback: 按字符估算
+    ratio = max_tokens / max(count_tokens(text), 1)
+    if ratio >= 1:
+        return text, count_tokens(text)
+    cut = int(len(text) * ratio)
+    return text[:cut], max_tokens
+
+
+def handler(event, context):
+    job_id = event['job_id']
+    update_job(job_id, status='SUMMARIZING')
+
+    # Load transcribe output
+    obj = s3.get_object(Bucket=S3_BUCKET, Key=f"jobs/{job_id}/transcribe-output.json")
+    data = json.loads(obj['Body'].read())
+
+    # Simplify: merge items by speaker
+    simplified = simplify_transcript(data)
+
+    # Save simplified
+    s3.put_object(
+        Bucket=S3_BUCKET,
+        Key=f"jobs/{job_id}/simplified.txt",
+        Body=simplified.encode('utf-8'),
+    )
+
+    # LLM config
+    llm_url = get_ssm('llm/api_url')
+    llm_key = get_ssm('llm/api_key')
+    llm_model = get_ssm('llm/model')
+
+    client = OpenAI(api_key=llm_key, base_url=llm_url.rstrip('/'), timeout=600)
+
+    # 计算可用 token 预算
+    context_limit = MODEL_CONTEXT.get(llm_model, MODEL_CONTEXT['default'])
+    system_tokens = count_tokens(SYSTEM_PROMPT) + 20  # 消息格式开销
+    template_tokens = count_tokens(USER_PROMPT_TEMPLATE.replace('{transcript}', ''))
+    available = context_limit - system_tokens - template_tokens
+
+    # 截断转录到可用 token 数
+    transcript_text, used_tokens = truncate_to_tokens(simplified, available)
+    total_tokens = count_tokens(simplified)
+
+    print(f"[summarize] 转录总 token: {total_tokens}, 可用: {available}, 实际输入: {used_tokens}")
+    if used_tokens < total_tokens:
+        print(f"[summarize] 截断: {total_tokens} -> {used_tokens} tokens ({used_tokens/total_tokens*100:.1f}%)")
+
+    prompt = USER_PROMPT_TEMPLATE.format(transcript=transcript_text)
+
+    response = client.responses.create(
+        model=llm_model,
+        instructions=SYSTEM_PROMPT,
+        input=[{'role': 'user', 'content': prompt}],
+        max_output_tokens=128000,
+    )
+    summary = response.output_text
+
+    # 记录 usage
+    usage = response.usage
+    if usage:
+        print(f"[summarize] LLM usage: input={usage.input_tokens}, output={usage.output_tokens}, total={usage.total_tokens}")
+
+    # Save summary
+    s3.put_object(
+        Bucket=S3_BUCKET,
+        Key=f"jobs/{job_id}/summary.md",
+        Body=summary.encode('utf-8'),
+    )
+
+    update_job(job_id, status='SUMMARIZED')
+    return {**event, 'summary': summary}
+
+
+def simplify_transcript(data):
+    items = data.get('results', {}).get('items', [])
+    paragraphs = []
+    current_speaker = None
+    current_text = []
+    current_start = None
+
+    for item in items:
+        speaker = item.get('speaker_label', '')
+        content = item['alternatives'][0]['content']
+        if item['type'] == 'punctuation':
+            if current_text:
+                current_text.append(content)
+            continue
+        if speaker != current_speaker and current_text:
+            paragraphs.append((current_speaker, current_start, ''.join(current_text).strip()))
+            current_text = []
+        if speaker != current_speaker:
+            current_speaker = speaker
+            current_start = item.get('start_time', '')
+        if current_text:
+            prev = current_text[-1]
+            if prev and prev[-1].isascii() and content[0].isascii():
+                current_text.append(' ')
+        current_text.append(content)
+
+    if current_text:
+        paragraphs.append((current_speaker, current_start, ''.join(current_text).strip()))
+
+    lines = []
+    for spk, start, text in paragraphs:
+        ts = ''
+        if start:
+            secs = float(start)
+            ts = f"[{int(secs//60):02d}:{int(secs%60):02d}]"
+        lines.append(f"**{spk}** {ts}")
+        lines.append(text)
+        lines.append('')
+    return '\n'.join(lines)

+ 13 - 0
sp-transcribe-service/lambdas/transcribe_check.py

@@ -0,0 +1,13 @@
+"""Check Amazon Transcribe job status."""
+import boto3
+
+transcribe = boto3.client('transcribe')
+
+
+def handler(event, context):
+    transcribe_job = event['transcribe_job']
+
+    resp = transcribe.get_transcription_job(TranscriptionJobName=transcribe_job)
+    status = resp['TranscriptionJob']['TranscriptionJobStatus']
+
+    return {**event, 'transcribe_status': status}

+ 48 - 0
sp-transcribe-service/lambdas/transcribe_start.py

@@ -0,0 +1,48 @@
+"""Start Amazon Transcribe job with language selection."""
+import os
+import boto3
+from shared import S3_BUCKET, update_job
+
+transcribe = boto3.client('transcribe')
+
+AUTO_DETECT_LANGUAGES = ['zh-CN', 'zh-TW', 'yue-CN', 'en-US']
+
+# Amazon Transcribe 语言代码映射
+LANG_MAP = {
+    'zh-CN': 'zh-CN',
+    'zh-TW': 'zh-TW',
+    'zh-HK': 'zh-HK',
+    'en-US': 'en-US',
+}
+
+
+def handler(event, context):
+    job_id = event['job_id']
+    audio_s3_key = event['audio_s3_key']
+    language = event.get('language', 'auto')
+    transcribe_job = f"sp-{job_id}"
+
+    update_job(job_id, status='TRANSCRIBING')
+
+    params = {
+        'TranscriptionJobName': transcribe_job,
+        'Media': {'MediaFileUri': f"s3://{S3_BUCKET}/{audio_s3_key}"},
+        'OutputBucketName': S3_BUCKET,
+        'OutputKey': f"jobs/{job_id}/transcribe-output.json",
+        'Settings': {
+            'ShowSpeakerLabels': True,
+            'MaxSpeakerLabels': 10,
+        },
+    }
+
+    if language == 'auto':
+        # 自动语言识别,提供候选语言列表
+        params['IdentifyLanguage'] = True
+        params['LanguageOptions'] = AUTO_DETECT_LANGUAGES
+    else:
+        # 指定语言
+        params['LanguageCode'] = LANG_MAP.get(language, language)
+
+    transcribe.start_transcription_job(**params)
+
+    return {**event, 'transcribe_job': transcribe_job}

+ 69 - 0
sp-transcribe-service/scripts/build_lambdas.py

@@ -0,0 +1,69 @@
+#!/usr/bin/env python3
+"""打包 Lambda 函数为 zip,跨平台 (Windows/Linux/Mac)。"""
+import os
+import sys
+import shutil
+import zipfile
+import subprocess
+
+SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
+LAMBDAS_DIR = os.path.join(SCRIPT_DIR, '..', 'lambdas')
+LAMBDAS_DIR = os.path.normpath(LAMBDAS_DIR)
+
+FUNCTIONS = ['submit', 'download', 'transcribe_start', 'transcribe_check', 'summarize', 'notify']
+
+
+def build_layer():
+    """打包 Lambda Layer (requests + pycryptodome)"""
+    print("Building Lambda layer...")
+    layer_dir = os.path.join(LAMBDAS_DIR, 'layer_build', 'python')
+    layer_zip = os.path.join(LAMBDAS_DIR, 'layer.zip')
+
+    if os.path.exists(os.path.join(LAMBDAS_DIR, 'layer_build')):
+        shutil.rmtree(os.path.join(LAMBDAS_DIR, 'layer_build'))
+    os.makedirs(layer_dir)
+
+    # 全部指定 Linux 平台安装,确保 native 包兼容 Lambda
+    subprocess.check_call([
+        sys.executable, '-m', 'pip', 'install',
+        'requests', 'pycryptodome', 'openai', 'tiktoken',
+        '--platform', 'manylinux2014_x86_64',
+        '--implementation', 'cp',
+        '--python-version', '3.12',
+        '--only-binary=:all:',
+        '-t', layer_dir, '-q',
+    ])
+
+    with zipfile.ZipFile(layer_zip, 'w', zipfile.ZIP_DEFLATED) as zf:
+        for root, dirs, files in os.walk(os.path.join(LAMBDAS_DIR, 'layer_build')):
+            for f in files:
+                full = os.path.join(root, f)
+                arcname = os.path.relpath(full, os.path.join(LAMBDAS_DIR, 'layer_build'))
+                zf.write(full, arcname)
+
+    shutil.rmtree(os.path.join(LAMBDAS_DIR, 'layer_build'))
+    size = os.path.getsize(layer_zip) / 1024 / 1024
+    print(f"  layer.zip ({size:.1f} MB)")
+
+
+def build_function(name):
+    """打包单个 Lambda 函数"""
+    zip_path = os.path.join(LAMBDAS_DIR, f'{name}.zip')
+    with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zf:
+        zf.write(os.path.join(LAMBDAS_DIR, f'{name}.py'), f'{name}.py')
+        zf.write(os.path.join(LAMBDAS_DIR, 'shared.py'), 'shared.py')
+    size = os.path.getsize(zip_path) / 1024
+    print(f"  {name}.zip ({size:.1f} KB)")
+
+
+def main():
+    print(f"Lambda 目录: {LAMBDAS_DIR}\n")
+    build_layer()
+    print()
+    for fn in FUNCTIONS:
+        build_function(fn)
+    print("\n完成!")
+
+
+if __name__ == '__main__':
+    main()

+ 23 - 0
sp-transcribe-service/scripts/build_lambdas.sh

@@ -0,0 +1,23 @@
+#!/bin/bash
+# Build Lambda deployment packages
+set -e
+cd "$(dirname "$0")/../lambdas"
+
+# Build layer (shared dependencies)
+echo "Building Lambda layer..."
+rm -rf layer_build layer.zip
+mkdir -p layer_build/python
+pip install requests pycryptodome -t layer_build/python -q
+cd layer_build && zip -r ../layer.zip python -q && cd ..
+rm -rf layer_build
+echo "  layer.zip created"
+
+# Build each function
+for fn in submit download transcribe_start transcribe_check summarize notify; do
+    echo "Building ${fn}..."
+    rm -f ${fn}.zip
+    zip ${fn}.zip ${fn}.py shared.py -q
+    echo "  ${fn}.zip created"
+done
+
+echo "Done! All Lambda packages built."

+ 77 - 0
sp-transcribe-service/scripts/manage_users.py

@@ -0,0 +1,77 @@
+#!/usr/bin/env python3
+"""管理用户 API Key。
+
+用法:
+  python manage_users.py add --email user@example.com --name "User Name"
+  python manage_users.py list
+  python manage_users.py delete --api-key <key>
+"""
+import argparse
+import secrets
+import time
+import boto3
+
+TABLE_NAME = 'sp-transcribe-users'
+
+
+def add_user(email: str, name: str):
+    ddb = boto3.resource('dynamodb')
+    table = ddb.Table(TABLE_NAME)
+    api_key = secrets.token_urlsafe(32)
+    table.put_item(Item={
+        'api_key': api_key,
+        'email': email,
+        'name': name,
+        'created_at': int(time.time()),
+    })
+    print(f"用户已创建:")
+    print(f"  Name:    {name}")
+    print(f"  Email:   {email}")
+    print(f"  API Key: {api_key}")
+
+
+def list_users():
+    ddb = boto3.resource('dynamodb')
+    table = ddb.Table(TABLE_NAME)
+    resp = table.scan()
+    items = resp.get('Items', [])
+    if not items:
+        print("无用户")
+        return
+    for u in items:
+        print(f"  {u.get('name', '?'):20s} {u.get('email', '?'):30s} {u['api_key']}")
+
+
+def delete_user(api_key: str):
+    ddb = boto3.resource('dynamodb')
+    table = ddb.Table(TABLE_NAME)
+    table.delete_item(Key={'api_key': api_key})
+    print(f"已删除: {api_key}")
+
+
+def main():
+    parser = argparse.ArgumentParser(description="管理 SP Transcribe 用户")
+    sub = parser.add_subparsers(dest='cmd')
+
+    p_add = sub.add_parser('add')
+    p_add.add_argument('--email', required=True)
+    p_add.add_argument('--name', required=True)
+
+    sub.add_parser('list')
+
+    p_del = sub.add_parser('delete')
+    p_del.add_argument('--api-key', required=True)
+
+    args = parser.parse_args()
+    if args.cmd == 'add':
+        add_user(args.email, args.name)
+    elif args.cmd == 'list':
+        list_users()
+    elif args.cmd == 'delete':
+        delete_user(args.api_key)
+    else:
+        parser.print_help()
+
+
+if __name__ == '__main__':
+    main()

+ 25 - 0
sp-transcribe-service/terraform/.terraform.lock.hcl

@@ -0,0 +1,25 @@
+# This file is maintained automatically by "terraform init".
+# Manual edits may be lost in future updates.
+
+provider "registry.terraform.io/hashicorp/aws" {
+  version     = "5.100.0"
+  constraints = "~> 5.0"
+  hashes = [
+    "h1:H3mU/7URhP0uCRGK8jeQRKxx2XFzEqLiOq/L2Bbiaxs=",
+    "zh:054b8dd49f0549c9a7cc27d159e45327b7b65cf404da5e5a20da154b90b8a644",
+    "zh:0b97bf8d5e03d15d83cc40b0530a1f84b459354939ba6f135a0086c20ebbe6b2",
+    "zh:1589a2266af699cbd5d80737a0fe02e54ec9cf2ca54e7e00ac51c7359056f274",
+    "zh:6330766f1d85f01ae6ea90d1b214b8b74cc8c1badc4696b165b36ddd4cc15f7b",
+    "zh:7c8c2e30d8e55291b86fcb64bdf6c25489d538688545eb48fd74ad622e5d3862",
+    "zh:99b1003bd9bd32ee323544da897148f46a527f622dc3971af63ea3e251596342",
+    "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425",
+    "zh:9f8b909d3ec50ade83c8062290378b1ec553edef6a447c56dadc01a99f4eaa93",
+    "zh:aaef921ff9aabaf8b1869a86d692ebd24fbd4e12c21205034bb679b9caf883a2",
+    "zh:ac882313207aba00dd5a76dbd572a0ddc818bb9cbf5c9d61b28fe30efaec951e",
+    "zh:bb64e8aff37becab373a1a0cc1080990785304141af42ed6aa3dd4913b000421",
+    "zh:dfe495f6621df5540d9c92ad40b8067376350b005c637ea6efac5dc15028add4",
+    "zh:f0ddf0eaf052766cfe09dea8200a946519f653c384ab4336e2a4a64fdd6310e9",
+    "zh:f1b7e684f4c7ae1eed272b6de7d2049bb87a0275cb04dbb7cda6636f600699c9",
+    "zh:ff461571e3f233699bf690db319dfe46aec75e58726636a0d97dd9ac6e32fb70",
+  ]
+}

+ 52 - 0
sp-transcribe-service/terraform/api_gateway.tf

@@ -0,0 +1,52 @@
+# ============================================================
+# API Gateway (HTTP API)
+# ============================================================
+resource "aws_apigatewayv2_api" "api" {
+  name          = "${local.prefix}-api"
+  protocol_type = "HTTP"
+
+  cors_configuration {
+    allow_origins = ["*"]
+    allow_methods = ["POST", "OPTIONS"]
+    allow_headers = ["Content-Type", "X-Api-Key"]
+    max_age       = 86400
+  }
+}
+
+resource "aws_apigatewayv2_stage" "default" {
+  api_id      = aws_apigatewayv2_api.api.id
+  name        = "$default"
+  auto_deploy = true
+}
+
+resource "aws_apigatewayv2_integration" "submit" {
+  api_id                 = aws_apigatewayv2_api.api.id
+  integration_type       = "AWS_PROXY"
+  integration_uri        = aws_lambda_function.submit.invoke_arn
+  payload_format_version = "2.0"
+}
+
+resource "aws_apigatewayv2_route" "submit" {
+  api_id    = aws_apigatewayv2_api.api.id
+  route_key = "POST /submit"
+  target    = "integrations/${aws_apigatewayv2_integration.submit.id}"
+}
+
+resource "aws_lambda_permission" "apigw" {
+  statement_id  = "AllowAPIGateway"
+  action        = "lambda:InvokeFunction"
+  function_name = aws_lambda_function.submit.function_name
+  principal     = "apigateway.amazonaws.com"
+  source_arn    = "${aws_apigatewayv2_api.api.execution_arn}/*/*"
+}
+
+# ============================================================
+# Outputs
+# ============================================================
+output "api_endpoint" {
+  value = aws_apigatewayv2_stage.default.invoke_url
+}
+
+output "s3_bucket" {
+  value = aws_s3_bucket.media.id
+}

+ 152 - 0
sp-transcribe-service/terraform/lambda.tf

@@ -0,0 +1,152 @@
+# ============================================================
+# Lambda Layer (shared dependencies)
+# ============================================================
+resource "aws_lambda_layer_version" "deps" {
+  filename            = "${path.module}/../lambdas/layer.zip"
+  layer_name          = "${local.prefix}-deps"
+  compatible_runtimes = ["python3.12"]
+  source_code_hash    = filebase64sha256("${path.module}/../lambdas/layer.zip")
+}
+
+# ============================================================
+# IAM Role for Lambdas
+# ============================================================
+resource "aws_iam_role" "lambda" {
+  name = "${local.prefix}-lambda-role"
+  assume_role_policy = jsonencode({
+    Version = "2012-10-17"
+    Statement = [{
+      Action = "sts:AssumeRole"
+      Effect = "Allow"
+      Principal = { Service = "lambda.amazonaws.com" }
+    }]
+  })
+}
+
+resource "aws_iam_role_policy" "lambda" {
+  name = "${local.prefix}-lambda-policy"
+  role = aws_iam_role.lambda.id
+  policy = jsonencode({
+    Version = "2012-10-17"
+    Statement = [
+      {
+        Effect   = "Allow"
+        Action   = ["logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents"]
+        Resource = "arn:aws:logs:*:*:*"
+      },
+      {
+        Effect   = "Allow"
+        Action   = ["s3:PutObject", "s3:GetObject", "s3:DeleteObject"]
+        Resource = "${aws_s3_bucket.media.arn}/*"
+      },
+      {
+        Effect   = "Allow"
+        Action   = ["dynamodb:GetItem", "dynamodb:PutItem", "dynamodb:UpdateItem", "dynamodb:Scan"]
+        Resource = [aws_dynamodb_table.users.arn, aws_dynamodb_table.jobs.arn]
+      },
+      {
+        Effect   = "Allow"
+        Action   = ["transcribe:StartTranscriptionJob", "transcribe:GetTranscriptionJob"]
+        Resource = "*"
+      },
+      {
+        Effect   = "Allow"
+        Action   = ["ssm:GetParameter", "ssm:GetParameters"]
+        Resource = "arn:aws:ssm:${var.aws_region}:*:parameter/${local.prefix}/*"
+      },
+      {
+        Effect   = "Allow"
+        Action   = ["states:StartExecution"]
+        Resource = aws_sfn_state_machine.pipeline.arn
+      },
+    ]
+  })
+}
+
+# ============================================================
+# Lambda Functions
+# ============================================================
+locals {
+  lambda_env = {
+    PROJECT     = local.prefix
+    S3_BUCKET   = aws_s3_bucket.media.id
+    USERS_TABLE = aws_dynamodb_table.users.name
+    JOBS_TABLE  = aws_dynamodb_table.jobs.name
+    SSM_PREFIX  = "/${local.prefix}"
+  }
+}
+
+resource "aws_lambda_function" "submit" {
+  filename         = "${path.module}/../lambdas/submit.zip"
+  function_name    = "${local.prefix}-submit"
+  role             = aws_iam_role.lambda.arn
+  handler          = "submit.handler"
+  runtime          = "python3.12"
+  timeout          = 30
+  source_code_hash = filebase64sha256("${path.module}/../lambdas/submit.zip")
+  layers           = [aws_lambda_layer_version.deps.arn]
+  environment {
+    variables = merge(local.lambda_env, {
+      STATE_MACHINE = aws_sfn_state_machine.pipeline.arn
+    })
+  }
+}
+
+resource "aws_lambda_function" "download" {
+  filename         = "${path.module}/../lambdas/download.zip"
+  function_name    = "${local.prefix}-download"
+  role             = aws_iam_role.lambda.arn
+  handler          = "download.handler"
+  runtime          = "python3.12"
+  timeout          = 900
+  memory_size      = 1024
+  source_code_hash = filebase64sha256("${path.module}/../lambdas/download.zip")
+  layers           = [aws_lambda_layer_version.deps.arn]
+  environment { variables = local.lambda_env }
+}
+
+resource "aws_lambda_function" "transcribe" {
+  filename         = "${path.module}/../lambdas/transcribe_start.zip"
+  function_name    = "${local.prefix}-transcribe"
+  role             = aws_iam_role.lambda.arn
+  handler          = "transcribe_start.handler"
+  runtime          = "python3.12"
+  timeout          = 60
+  source_code_hash = filebase64sha256("${path.module}/../lambdas/transcribe_start.zip")
+  environment { variables = local.lambda_env }
+}
+
+resource "aws_lambda_function" "check" {
+  filename         = "${path.module}/../lambdas/transcribe_check.zip"
+  function_name    = "${local.prefix}-check"
+  role             = aws_iam_role.lambda.arn
+  handler          = "transcribe_check.handler"
+  runtime          = "python3.12"
+  timeout          = 60
+  source_code_hash = filebase64sha256("${path.module}/../lambdas/transcribe_check.zip")
+  environment { variables = local.lambda_env }
+}
+
+resource "aws_lambda_function" "summarize" {
+  filename         = "${path.module}/../lambdas/summarize.zip"
+  function_name    = "${local.prefix}-summarize"
+  role             = aws_iam_role.lambda.arn
+  handler          = "summarize.handler"
+  runtime          = "python3.12"
+  timeout          = 900
+  memory_size      = 512
+  source_code_hash = filebase64sha256("${path.module}/../lambdas/summarize.zip")
+  layers           = [aws_lambda_layer_version.deps.arn]
+  environment { variables = local.lambda_env }
+}
+
+resource "aws_lambda_function" "notify" {
+  filename         = "${path.module}/../lambdas/notify.zip"
+  function_name    = "${local.prefix}-notify"
+  role             = aws_iam_role.lambda.arn
+  handler          = "notify.handler"
+  runtime          = "python3.12"
+  timeout          = 60
+  source_code_hash = filebase64sha256("${path.module}/../lambdas/notify.zip")
+  environment { variables = local.lambda_env }
+}

+ 136 - 0
sp-transcribe-service/terraform/main.tf

@@ -0,0 +1,136 @@
+terraform {
+  required_version = ">= 1.5"
+  required_providers {
+    aws = { source = "hashicorp/aws", version = "~> 5.0" }
+  }
+}
+
+provider "aws" {
+  region = var.aws_region
+}
+
+# ============================================================
+# Variables
+# ============================================================
+variable "aws_region" {
+  default = "ap-northeast-1"
+}
+variable "project" {
+  default = "sp-transcribe"
+}
+variable "smtp_host" { type = string }
+variable "smtp_port" {
+  type    = number
+  default = 587
+}
+variable "smtp_user" { type = string }
+variable "smtp_pass" {
+  type      = string
+  sensitive = true
+}
+variable "smtp_from" { type = string }
+variable "llm_api_url" { type = string }
+variable "llm_api_key" {
+  type      = string
+  sensitive = true
+}
+variable "llm_model" {
+  type    = string
+  default = "gpt-4o-mini"
+}
+
+locals {
+  prefix = var.project
+}
+
+# ============================================================
+# S3 Bucket
+# ============================================================
+resource "aws_s3_bucket" "media" {
+  bucket_prefix = "${local.prefix}-media-"
+  force_destroy = true
+}
+
+resource "aws_s3_bucket_lifecycle_configuration" "media" {
+  bucket = aws_s3_bucket.media.id
+  rule {
+    id     = "cleanup"
+    status = "Enabled"
+    filter {}
+    expiration { days = 7 }
+  }
+}
+
+# ============================================================
+# DynamoDB
+# ============================================================
+resource "aws_dynamodb_table" "users" {
+  name         = "${local.prefix}-users"
+  billing_mode = "PAY_PER_REQUEST"
+  hash_key     = "api_key"
+
+  attribute {
+    name = "api_key"
+    type = "S"
+  }
+}
+
+resource "aws_dynamodb_table" "jobs" {
+  name         = "${local.prefix}-jobs"
+  billing_mode = "PAY_PER_REQUEST"
+  hash_key     = "job_id"
+
+  attribute {
+    name = "job_id"
+    type = "S"
+  }
+
+  ttl {
+    attribute_name = "ttl"
+    enabled        = true
+  }
+}
+
+# ============================================================
+# SSM Parameters (secrets)
+# ============================================================
+resource "aws_ssm_parameter" "smtp_host" {
+  name  = "/${local.prefix}/smtp/host"
+  type  = "String"
+  value = var.smtp_host
+}
+resource "aws_ssm_parameter" "smtp_port" {
+  name  = "/${local.prefix}/smtp/port"
+  type  = "String"
+  value = tostring(var.smtp_port)
+}
+resource "aws_ssm_parameter" "smtp_user" {
+  name  = "/${local.prefix}/smtp/user"
+  type  = "String"
+  value = var.smtp_user
+}
+resource "aws_ssm_parameter" "smtp_pass" {
+  name  = "/${local.prefix}/smtp/pass"
+  type  = "SecureString"
+  value = var.smtp_pass
+}
+resource "aws_ssm_parameter" "smtp_from" {
+  name  = "/${local.prefix}/smtp/from"
+  type  = "String"
+  value = var.smtp_from
+}
+resource "aws_ssm_parameter" "llm_api_url" {
+  name  = "/${local.prefix}/llm/api_url"
+  type  = "String"
+  value = var.llm_api_url
+}
+resource "aws_ssm_parameter" "llm_api_key" {
+  name  = "/${local.prefix}/llm/api_key"
+  type  = "SecureString"
+  value = var.llm_api_key
+}
+resource "aws_ssm_parameter" "llm_model" {
+  name  = "/${local.prefix}/llm/model"
+  type  = "String"
+  value = var.llm_model
+}

+ 99 - 0
sp-transcribe-service/terraform/step_functions.tf

@@ -0,0 +1,99 @@
+# ============================================================
+# Step Functions State Machine
+# ============================================================
+resource "aws_iam_role" "sfn" {
+  name = "${local.prefix}-sfn-role"
+  assume_role_policy = jsonencode({
+    Version = "2012-10-17"
+    Statement = [{
+      Action = "sts:AssumeRole"
+      Effect = "Allow"
+      Principal = { Service = "states.amazonaws.com" }
+    }]
+  })
+}
+
+resource "aws_iam_role_policy" "sfn" {
+  name = "${local.prefix}-sfn-policy"
+  role = aws_iam_role.sfn.id
+  policy = jsonencode({
+    Version = "2012-10-17"
+    Statement = [{
+      Effect   = "Allow"
+      Action   = "lambda:InvokeFunction"
+      Resource = [
+        aws_lambda_function.download.arn,
+        aws_lambda_function.transcribe.arn,
+        aws_lambda_function.check.arn,
+        aws_lambda_function.summarize.arn,
+        aws_lambda_function.notify.arn,
+      ]
+    }]
+  })
+}
+
+resource "aws_sfn_state_machine" "pipeline" {
+  name     = "${local.prefix}-pipeline"
+  role_arn = aws_iam_role.sfn.arn
+
+  definition = jsonencode({
+    Comment = "SP Stream Transcribe Pipeline"
+    StartAt = "Download"
+    States = {
+      Download = {
+        Type     = "Task"
+        Resource = aws_lambda_function.download.arn
+        Next     = "StartTranscribe"
+        Retry    = [{ ErrorEquals = ["States.ALL"], MaxAttempts = 2, IntervalSeconds = 10 }]
+        Catch    = [{ ErrorEquals = ["States.ALL"], Next = "NotifyError", ResultPath = "$.error_info" }]
+      }
+      StartTranscribe = {
+        Type     = "Task"
+        Resource = aws_lambda_function.transcribe.arn
+        Next     = "WaitForTranscribe"
+        Catch    = [{ ErrorEquals = ["States.ALL"], Next = "NotifyError", ResultPath = "$.error_info" }]
+      }
+      WaitForTranscribe = {
+        Type    = "Wait"
+        Seconds = 30
+        Next    = "CheckTranscribe"
+      }
+      CheckTranscribe = {
+        Type     = "Task"
+        Resource = aws_lambda_function.check.arn
+        Next     = "IsTranscribeComplete"
+        Catch    = [{ ErrorEquals = ["States.ALL"], Next = "NotifyError", ResultPath = "$.error_info" }]
+      }
+      IsTranscribeComplete = {
+        Type = "Choice"
+        Choices = [{
+          Variable     = "$.transcribe_status"
+          StringEquals = "COMPLETED"
+          Next         = "Summarize"
+        }, {
+          Variable     = "$.transcribe_status"
+          StringEquals = "FAILED"
+          Next         = "NotifyError"
+        }]
+        Default = "WaitForTranscribe"
+      }
+      Summarize = {
+        Type     = "Task"
+        Resource = aws_lambda_function.summarize.arn
+        Next     = "Notify"
+        Retry    = [{ ErrorEquals = ["States.ALL"], MaxAttempts = 3, IntervalSeconds = 30, BackoffRate = 2 }]
+        Catch    = [{ ErrorEquals = ["States.ALL"], Next = "NotifyError", ResultPath = "$.error_info" }]
+      }
+      Notify = {
+        Type     = "Task"
+        Resource = aws_lambda_function.notify.arn
+        End      = true
+      }
+      NotifyError = {
+        Type     = "Task"
+        Resource = aws_lambda_function.notify.arn
+        End      = true
+      }
+    }
+  })
+}

+ 9 - 0
sp-transcribe-service/terraform/terraform.tfvars.example

@@ -0,0 +1,9 @@
+aws_region  = "ap-northeast-1"
+smtp_host   = "smtp.example.com"
+smtp_port   = 587
+smtp_user   = "user@example.com"
+smtp_pass   = "your-smtp-password"
+smtp_from   = "noreply@example.com"
+llm_api_url = "https://api.openai.com/v1"
+llm_api_key = "sk-xxx"
+llm_model   = "gpt-4o-mini"

+ 317 - 0
sp-transcribe-service/userscript/sp-stream-helper.user.js

@@ -0,0 +1,317 @@
+// ==UserScript==
+// @name         SharePoint Stream Helper
+// @namespace    https://github.com/sp-stream-helper
+// @version      2.0
+// @description  一键提交 SharePoint Stream 视频到转录+总结服务
+// @match        https://*.sharepoint.com/personal/*/_layouts/*/stream.aspx*
+// @match        https://*.sharepoint.com/sites/*/_layouts/*/stream.aspx*
+// @grant        none
+// @run-at       document-start
+// ==/UserScript==
+
+(function () {
+    'use strict';
+
+    let capturedManifest = null;
+    let btnReady = false;
+
+    // ========================================
+    // Settings (stored via GM_setValue)
+    // ========================================
+    const DEFAULTS = {
+        apiEndpoint: '',
+        apiKey: '',
+    };
+
+    function getSetting(key) {
+        try { return localStorage.getItem('sp_helper_' + key) || DEFAULTS[key]; }
+        catch { return DEFAULTS[key]; }
+    }
+    function setSetting(key, val) {
+        try { localStorage.setItem('sp_helper_' + key, val); }
+        catch {}
+    }
+
+    // ========================================
+    // Intercept fetch/XHR at document-start
+    // ========================================
+    const origFetch = window.fetch;
+    window.fetch = function (input, init) {
+        try {
+            const url = typeof input === 'string' ? input : (input?.url || '');
+            const method = (init?.method || input?.method || 'GET').toUpperCase();
+            const headers = extractHeaders(init?.headers || input?.headers);
+            maybeCapture(method, url, headers);
+        } catch (e) {}
+        return origFetch.apply(this, arguments);
+    };
+
+    const origOpen = XMLHttpRequest.prototype.open;
+    const origSetHeader = XMLHttpRequest.prototype.setRequestHeader;
+    const origSend = XMLHttpRequest.prototype.send;
+    XMLHttpRequest.prototype.open = function (method, url) {
+        this._spH = { method: (method || 'GET').toUpperCase(), url: url || '', headers: {} };
+        return origOpen.apply(this, arguments);
+    };
+    XMLHttpRequest.prototype.setRequestHeader = function (name, value) {
+        if (this._spH) this._spH.headers[name] = value;
+        return origSetHeader.apply(this, arguments);
+    };
+    XMLHttpRequest.prototype.send = function () {
+        if (this._spH) maybeCapture(this._spH.method, this._spH.url, this._spH.headers);
+        return origSend.apply(this, arguments);
+    };
+
+    function extractHeaders(raw) {
+        const h = {};
+        if (!raw) return h;
+        if (raw instanceof Headers) raw.forEach((v, k) => { h[k] = v; });
+        else if (Array.isArray(raw)) raw.forEach(([k, v]) => { h[k] = v; });
+        else Object.keys(raw).forEach(k => { h[k] = raw[k]; });
+        return h;
+    }
+
+    function maybeCapture(method, url, headers) {
+        if (!url.includes('videomanifest') || method === 'OPTIONS') return;
+        if (!Object.keys(headers).some(k => k.toLowerCase() === 'x-spopactoken')) return;
+        capturedManifest = { method, url, headers: { ...headers }, time: new Date().toLocaleTimeString() };
+        console.log('[SP Helper] ✅ Captured videomanifest');
+        if (btnReady) updateUI();
+    }
+
+    // ========================================
+    // Generate cURL
+    // ========================================
+    function toCurl(req) {
+        let pacToken = '', origin = '', referer = '';
+        for (const [k, v] of Object.entries(req.headers)) {
+            const l = k.toLowerCase();
+            if (l === 'x-spopactoken') pacToken = v;
+            if (l === 'origin') origin = v;
+            if (l === 'referer') referer = v;
+        }
+        const parts = [`curl '${req.url}'`];
+        parts.push(`  -H 'accept: */*'`);
+        parts.push(`  -H 'accept-language: zh-CN,zh;q=0.9'`);
+        if (origin) parts.push(`  -H 'origin: ${origin}'`);
+        parts.push(`  -H 'priority: u=1, i'`);
+        if (referer) parts.push(`  -H 'referer: ${referer}'`);
+        parts.push(`  -H 'sec-ch-ua: "Chromium";v="146", "Not-A.Brand";v="24", "Google Chrome";v="146"'`);
+        parts.push(`  -H 'sec-ch-ua-mobile: ?0'`);
+        parts.push(`  -H 'sec-ch-ua-platform: "Windows"'`);
+        parts.push(`  -H 'sec-fetch-dest: empty'`);
+        parts.push(`  -H 'sec-fetch-mode: cors'`);
+        parts.push(`  -H 'sec-fetch-site: cross-site'`);
+        parts.push(`  -H 'user-agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36'`);
+        if (pacToken) parts.push(`  -H 'x-spopactoken: ${pacToken.replace(/'/g, "'\\''")}'`);
+        return parts.join(' \\\n');
+    }
+
+    // ========================================
+    // UI
+    // ========================================
+    function createUI() {
+        const container = document.createElement('div');
+        container.id = 'sp-helper-ui';
+        container.innerHTML = `
+        <style>
+            #sp-helper-ui { position:fixed; bottom:20px; right:20px; z-index:2147483647; font-family:system-ui,sans-serif; font-size:13px; }
+            #sp-helper-ui .sp-btn { padding:10px 16px; border-radius:8px; border:none; cursor:pointer; color:#fff; font-size:13px;
+                box-shadow:0 2px 12px rgba(0,0,0,0.3); transition:all 0.2s; margin-left:8px; }
+            #sp-helper-ui .sp-btn:hover { filter:brightness(1.1); }
+            #sp-helper-ui .sp-btn.wait { background:#555; cursor:not-allowed; }
+            #sp-helper-ui .sp-btn.ready { background:#0078d4; }
+            #sp-helper-ui .sp-btn.ok { background:#107c10; }
+            #sp-helper-ui .sp-btn.submit { background:#d83b01; }
+            #sp-helper-panel { display:none; position:fixed; bottom:70px; right:20px; z-index:2147483647; background:#fff; border-radius:12px;
+                box-shadow:0 4px 24px rgba(0,0,0,0.2); padding:20px; width:360px; font-family:system-ui,sans-serif; font-size:13px; }
+            #sp-helper-panel h3 { margin:0 0 12px; font-size:15px; }
+            #sp-helper-panel label { display:block; margin:8px 0 4px; font-weight:600; }
+            #sp-helper-panel input, #sp-helper-panel textarea { width:100%; padding:6px 8px; border:1px solid #ccc; border-radius:4px;
+                font-size:12px; box-sizing:border-box; font-family:Consolas,monospace; }
+            #sp-helper-panel textarea { height:60px; resize:vertical; }
+            #sp-helper-panel .sp-row { display:flex; gap:8px; margin-top:12px; }
+            #sp-helper-panel .sp-row button { flex:1; padding:8px; border:none; border-radius:6px; cursor:pointer; font-size:13px; color:#fff; }
+        </style>
+        <div style="display:flex;align-items:center;">
+            <button id="sp-btn-copy" class="sp-btn wait">⏳ 等待播放...</button>
+            <button id="sp-btn-submit" class="sp-btn submit" style="display:none">🚀 提交转录</button>
+            <button id="sp-btn-settings" class="sp-btn" style="background:#666;padding:10px 12px" title="设置">⚙️</button>
+        </div>
+        <div id="sp-helper-panel">
+            <h3>⚙️ SP Stream Helper 设置</h3>
+            <label>API Endpoint</label>
+            <input id="sp-set-endpoint" placeholder="https://xxx.execute-api.ap-northeast-1.amazonaws.com">
+            <label>API Key</label>
+            <input id="sp-set-apikey" type="password" placeholder="your-api-key">
+            <div class="sp-row">
+                <button id="sp-set-save" style="background:#0078d4">💾 保存</button>
+                <button id="sp-set-close" style="background:#888">关闭</button>
+            </div>
+        </div>
+        <div id="sp-submit-panel" style="display:none;position:fixed;bottom:70px;right:20px;z-index:2147483647;background:#fff;border-radius:12px;box-shadow:0 4px 24px rgba(0,0,0,0.2);padding:20px;width:380px;font-family:system-ui,sans-serif;font-size:13px;">
+            <h3 style="margin:0 0 12px;font-size:15px;">🚀 提交转录任务</h3>
+            <label style="display:block;margin:8px 0 4px;font-weight:600;">Cookie (从 Cookie-Editor 导出)</label>
+            <textarea id="sp-submit-cookie" style="width:100%;height:80px;padding:6px 8px;border:1px solid #ccc;border-radius:4px;font-size:11px;box-sizing:border-box;font-family:Consolas,monospace;resize:vertical;" placeholder="粘贴 Cookie-Editor 导出的 JSON"></textarea>
+            <label style="display:block;margin:8px 0 4px;font-weight:600;">会议语言</label>
+            <select id="sp-submit-lang" style="width:100%;padding:6px 8px;border:1px solid #ccc;border-radius:4px;font-size:13px;">
+                <option value="auto">自动识别</option>
+                <option value="zh-CN" selected>中文 (简体)</option>
+                <option value="zh-TW">中文 (繁体)</option>
+                <option value="zh-HK">中文 (粤语)</option>
+                <option value="en-US">English (US)</option>
+            </select>
+            <div style="display:flex;gap:8px;margin-top:12px;">
+                <button id="sp-submit-go" style="flex:1;padding:8px;border:none;border-radius:6px;cursor:pointer;font-size:13px;color:#fff;background:#d83b01;">🚀 提交</button>
+                <button id="sp-submit-cancel" style="flex:1;padding:8px;border:none;border-radius:6px;cursor:pointer;font-size:13px;color:#fff;background:#888;">取消</button>
+            </div>
+        </div>`;
+        document.body.appendChild(container);
+
+        // Load settings
+        document.getElementById('sp-set-endpoint').value = getSetting('apiEndpoint');
+        document.getElementById('sp-set-apikey').value = getSetting('apiKey');
+
+        // Events
+        document.getElementById('sp-btn-copy').addEventListener('click', onCopy);
+        document.getElementById('sp-btn-submit').addEventListener('click', () => {
+            document.getElementById('sp-submit-panel').style.display = 'block';
+        });
+        document.getElementById('sp-submit-go').addEventListener('click', onSubmit);
+        document.getElementById('sp-submit-cancel').addEventListener('click', () => {
+            document.getElementById('sp-submit-panel').style.display = 'none';
+        });
+        document.getElementById('sp-btn-settings').addEventListener('click', () => {
+            const p = document.getElementById('sp-helper-panel');
+            p.style.display = p.style.display === 'none' ? 'block' : 'none';
+        });
+        document.getElementById('sp-set-save').addEventListener('click', () => {
+            setSetting('apiEndpoint', document.getElementById('sp-set-endpoint').value.trim());
+            setSetting('apiKey', document.getElementById('sp-set-apikey').value.trim());
+            document.getElementById('sp-helper-panel').style.display = 'none';
+            updateUI();
+        });
+        document.getElementById('sp-set-close').addEventListener('click', () => {
+            document.getElementById('sp-helper-panel').style.display = 'none';
+        });
+
+        btnReady = true;
+    }
+
+    function updateUI() {
+        const btnCopy = document.getElementById('sp-btn-copy');
+        const btnSubmit = document.getElementById('sp-btn-submit');
+        if (!btnCopy) return;
+
+        if (capturedManifest) {
+            btnCopy.className = 'sp-btn ready';
+            btnCopy.innerHTML = `📋 复制 cURL <span style="font-size:11px;opacity:0.6">${capturedManifest.time}</span>`;
+
+            if (getSetting('apiEndpoint') && getSetting('apiKey')) {
+                btnSubmit.style.display = 'inline-block';
+            }
+        }
+    }
+
+    function onCopy() {
+        if (!capturedManifest) return;
+        const curl = toCurl(capturedManifest);
+        navigator.clipboard.writeText(curl).catch(() => {
+            const ta = document.createElement('textarea');
+            ta.value = curl;
+            ta.style.cssText = 'position:fixed;left:-9999px';
+            document.body.appendChild(ta);
+            ta.select();
+            document.execCommand('copy');
+            ta.remove();
+        });
+        flashBtn('sp-btn-copy', '✅ 已复制!');
+    }
+
+    async function onSubmit() {
+        if (!capturedManifest) return;
+        const endpoint = getSetting('apiEndpoint');
+        const apiKey = getSetting('apiKey');
+        const cookie = document.getElementById('sp-submit-cookie').value.trim();
+        const lang = document.getElementById('sp-submit-lang').value;
+
+        if (!endpoint || !apiKey) {
+            alert('请先在设置中填写 API Endpoint 和 API Key');
+            return;
+        }
+        if (!cookie) {
+            alert('请粘贴 Cookie-Editor 导出的 Cookie JSON');
+            return;
+        }
+
+        const btn = document.getElementById('sp-submit-go');
+        btn.textContent = '⏳ 提交中...';
+        btn.disabled = true;
+
+        try {
+            const curl = toCurl(capturedManifest);
+            const resp = await fetch(endpoint.replace(/\/$/, '') + '/submit', {
+                method: 'POST',
+                headers: { 'Content-Type': 'application/json' },
+                body: JSON.stringify({ api_key: apiKey, curl, cookies: cookie, language: lang }),
+            });
+            const data = await resp.json();
+            if (resp.ok) {
+                document.getElementById('sp-submit-panel').style.display = 'none';
+                alert(`任务已提交!\nJob ID: ${data.job_id}\n完成后会发送邮件通知。`);
+            } else {
+                alert(`提交失败: ${data.error || resp.statusText}`);
+            }
+        } catch (e) {
+            alert(`提交失败: ${e.message}`);
+        } finally {
+            btn.textContent = '🚀 提交';
+            btn.disabled = false;
+        }
+    }
+
+    function flashBtn(id, text) {
+        const btn = document.getElementById(id);
+        if (!btn) return;
+        const prev = btn.innerHTML;
+        const prevBg = btn.style.background;
+        btn.innerHTML = text;
+        btn.style.background = '#107c10';
+        setTimeout(() => { btn.innerHTML = prev; btn.style.background = prevBg; }, 2000);
+    }
+
+    // ========================================
+    // Init
+    // ========================================
+    if (document.readyState === 'loading') {
+        document.addEventListener('DOMContentLoaded', init);
+    } else {
+        init();
+    }
+
+    function init() {
+        createUI();
+        if (capturedManifest) updateUI();
+
+        // PerformanceObserver fallback
+        try {
+            new PerformanceObserver((list) => {
+                for (const e of list.getEntries()) {
+                    if (e.name.includes('videomanifest') && !capturedManifest) {
+                        const pac = getPacToken();
+                        if (pac) {
+                            capturedManifest = { method: 'GET', url: e.name, headers: { 'x-spopactoken': pac }, time: new Date().toLocaleTimeString() };
+                            updateUI();
+                        }
+                    }
+                }
+            }).observe({ type: 'resource', buffered: true });
+        } catch (e) {}
+
+        console.log('[SP Helper] v2.0 loaded');
+    }
+
+    function getPacToken() {
+        try { return window.g_fileInfo?.['.driveAccessCode'] || window.g_fileInfo?.['.driveAccessCodeV21']; } catch { return null; }
+    }
+})();

+ 1040 - 0
sp_video_dl.py

@@ -0,0 +1,1040 @@
+#!/usr/bin/env python3
+"""
+SharePoint Stream 视频下载器
+用法:
+  1. 浏览器 DevTools -> Network -> 找到 videomanifest 请求
+  2. 右键 -> Copy as cURL (bash)
+  3. 粘贴到 curl_command.txt 文件中
+  4. 运行: python sp_video_dl.py curl_command.txt
+  
+  或者直接运行 python sp_video_dl.py 进入交互模式
+
+依赖: pip install requests pycryptodome
+"""
+
+import re
+import os
+import sys
+import json
+import shlex
+import struct
+import argparse
+import traceback
+from io import BytesIO
+from urllib.parse import unquote, urljoin, urlparse, parse_qs
+from xml.etree import ElementTree as ET
+from concurrent.futures import ThreadPoolExecutor, as_completed
+
+try:
+    import requests
+except ImportError:
+    print("需要安装 requests: pip install requests")
+    sys.exit(1)
+
+try:
+    from Crypto.Cipher import AES
+except ImportError:
+    AES = None
+    print("[警告] 未安装 pycryptodome,将跳过解密步骤")
+    print("  安装: pip install pycryptodome")
+
+
+# ============================================================
+# cURL 解析
+# ============================================================
+
+def parse_curl(curl_str: str) -> dict:
+    """解析 cURL 命令,提取 URL 和 headers"""
+    curl_str = curl_str.strip()
+    # 处理多行 (反斜杠换行)
+    curl_str = curl_str.replace("^\n", " ").replace("\\\n", " ").replace("^\r\n", " ").replace("\\\r\n", " ")
+    
+    # 尝试用 shlex 分词
+    try:
+        tokens = shlex.split(curl_str, posix=True)
+    except ValueError:
+        # Windows 的 PowerShell cURL 可能有不同的引号
+        curl_str = curl_str.replace("'", '"')
+        tokens = shlex.split(curl_str, posix=True)
+    
+    url = None
+    headers = {}
+    method = "GET"
+    
+    i = 0
+    while i < len(tokens):
+        t = tokens[i]
+        if t.lower() == "curl":
+            i += 1
+            continue
+        elif t in ("-H", "--header"):
+            i += 1
+            if i < len(tokens):
+                hdr = tokens[i]
+                if ":" in hdr:
+                    k, v = hdr.split(":", 1)
+                    headers[k.strip()] = v.strip()
+        elif t in ("-X", "--request"):
+            i += 1
+            if i < len(tokens):
+                method = tokens[i].upper()
+        elif t in ("--compressed", "-k", "--insecure"):
+            pass
+        elif t.startswith("http"):
+            url = t
+        elif not t.startswith("-") and url is None:
+            if t.startswith("http"):
+                url = t
+        i += 1
+    
+    # 如果没找到 URL,尝试正则
+    if not url:
+        m = re.search(r"(https?://[^\s'\"]+)", curl_str)
+        if m:
+            url = m.group(1).rstrip("'\"")
+    
+    if not url:
+        raise ValueError("无法从 cURL 命令中提取 URL")
+    
+    return {"url": url, "headers": headers, "method": method}
+
+
+def parse_raw_headers(text: str) -> dict:
+    """解析原始 HTTP headers 文本"""
+    headers = {}
+    url = None
+    
+    for line in text.strip().splitlines():
+        line = line.strip()
+        if not line:
+            continue
+        # 检查是否是 URL
+        if line.startswith("http"):
+            url = line
+            continue
+        # 检查是否是 header
+        if ":" in line:
+            k, v = line.split(":", 1)
+            k = k.strip()
+            v = v.strip()
+            # 跳过伪 headers
+            if not k.startswith(":"):
+                headers[k] = v
+            elif k == ":authority":
+                headers["Host"] = v
+    
+    return {"url": url, "headers": headers}
+
+
+# ============================================================
+# DASH Manifest 解析
+# ============================================================
+
+NS = {
+    "mpd": "urn:mpeg:DASH:schema:MPD:2011",
+    "sea": "urn:mpeg:dash:schema:sea:2012",
+}
+
+
+def parse_manifest(xml_text: str) -> dict:
+    """解析 DASH MPD manifest"""
+    root = ET.fromstring(xml_text)
+    
+    info = {
+        "duration_str": root.get("mediaPresentationDuration", ""),
+        "duration_sec": 0,
+        "base_url": "",
+        "tracks": [],
+    }
+    
+    # 解析时长 PT0H0M2555.584S
+    dur_m = re.match(r"PT(?:(\d+)H)?(?:(\d+)M)?(?:([\d.]+)S)?", info["duration_str"])
+    if dur_m:
+        h = int(dur_m.group(1) or 0)
+        m = int(dur_m.group(2) or 0)
+        s = float(dur_m.group(3) or 0)
+        info["duration_sec"] = h * 3600 + m * 60 + s
+    
+    base_el = root.find("mpd:BaseURL", NS)
+    if base_el is not None and base_el.text:
+        info["base_url"] = base_el.text.strip()
+    
+    period = root.find("mpd:Period", NS)
+    if period is None:
+        return info
+    
+    for adapt in period.findall("mpd:AdaptationSet", NS):
+        track = {
+            "id": adapt.get("id"),
+            "type": adapt.get("contentType"),  # audio / video
+            "mime": adapt.get("mimeType"),
+            "codecs": adapt.get("codecs"),
+            "label": "",
+            "width": adapt.get("maxWidth"),
+            "height": adapt.get("maxHeight"),
+            "key_url": "",
+            "iv": "",
+            "init_tpl": "",
+            "media_tpl": "",
+            "timescale": 0,
+            "segments": [],  # list of segment start times
+            "representations": [],
+        }
+        
+        label_el = adapt.find("mpd:Label", NS)
+        if label_el is not None and label_el.text:
+            track["label"] = label_el.text
+        
+        # 加密
+        cp = adapt.find("mpd:ContentProtection", NS)
+        if cp is not None:
+            crypto = cp.find("sea:CryptoPeriod", NS)
+            if crypto is not None:
+                track["key_url"] = crypto.get("keyUriTemplate", "").replace("&amp;", "&")
+                track["iv"] = crypto.get("IV", "")
+        
+        # SegmentTemplate
+        seg_tpl = adapt.find("mpd:SegmentTemplate", NS)
+        if seg_tpl is not None:
+            track["init_tpl"] = seg_tpl.get("initialization", "").replace("&amp;", "&")
+            track["media_tpl"] = seg_tpl.get("media", "").replace("&amp;", "&")
+            track["timescale"] = int(seg_tpl.get("timescale", "1"))
+            
+            timeline = seg_tpl.find("mpd:SegmentTimeline", NS)
+            if timeline is not None:
+                t = 0
+                for s in timeline.findall("mpd:S", NS):
+                    d = int(s.get("d", 0))
+                    r = int(s.get("r", 0))
+                    for _ in range(r + 1):
+                        track["segments"].append(t)
+                        t += d
+        
+        for rep in adapt.findall("mpd:Representation", NS):
+            track["representations"].append({
+                "id": rep.get("id"),
+                "bandwidth": int(rep.get("bandwidth", 0)),
+                "width": rep.get("width"),
+                "height": rep.get("height"),
+                "codecs": rep.get("codecs"),
+            })
+        
+        info["tracks"].append(track)
+    
+    return info
+
+
+def resolve_url(base_url: str, template: str, rep_id: str, seg_time: int = None) -> str:
+    """替换 DASH 模板中的占位符"""
+    url = template
+    # SharePoint 的模板占位符格式比较特殊,包含 GUID 后缀
+    # $RepresentationIDe963038e-16ab-4be8-8c6d-17d09a407520amp;
+    # $Timee963038e-16ab-4be8-8c6d-17d09a407520amp;
+    url = re.sub(r'\$RepresentationID[^&]*amp;', rep_id, url)
+    url = re.sub(r'\$RepresentationID\$', rep_id, url)
+    if seg_time is not None:
+        url = re.sub(r'\$Time[^&]*amp;', str(seg_time), url)
+        url = re.sub(r'\$Time\$', str(seg_time), url)
+    
+    full = base_url + url
+    return full
+
+
+# ============================================================
+# 下载器
+# ============================================================
+
+class SPVideoDownloader:
+    def __init__(self, manifest_url: str, headers: dict, output_dir: str = "output",
+                 cookies: str = None):
+        self.manifest_url = manifest_url
+        self.headers = headers
+        self.output_dir = output_dir
+        self.session = requests.Session()
+        self.session.headers.update({
+            "User-Agent": headers.get("user-agent", headers.get("User-Agent",
+                "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")),
+        })
+        
+        # 提取关键 headers
+        self.pac_token = None
+        for k, v in headers.items():
+            if k.lower() == "x-spopactoken":
+                self.pac_token = v
+                break
+        
+        self.origin = headers.get("origin", headers.get("Origin", ""))
+        self.referer = headers.get("referer", headers.get("Referer", ""))
+        
+        self.manifest_info = None
+        self.encryption_key = None
+        self.encryption_iv = None
+        self.sp_access_token = None
+        
+        # 设置 cookies (SharePoint 用 FedAuth/rtFa HttpOnly cookie 认证)
+        if cookies:
+            self._set_cookies(cookies)
+    
+    def _set_cookies(self, cookie_str: str):
+        """解析并设置 cookies
+        
+        支持格式:
+        1. JSON 文件路径 (Cookie-Editor / EditThisCookie 导出)
+        2. "name1=value1; name2=value2" (Cookie header 格式)
+        """
+        cookie_str = cookie_str.strip()
+        
+        # 从 manifest URL 提取 SharePoint domain
+        sp_domain = ""
+        parsed_manifest = urlparse(self.manifest_url)
+        # manifest 是 mediap.svc.ms,但 segment 去 sharepoint.com
+        # 从 docid 参数提取真实 domain
+        qs = parse_qs(parsed_manifest.query)
+        docid = qs.get('docid', [''])[0]
+        if docid:
+            docid_parsed = urlparse(unquote(docid))
+            sp_domain = docid_parsed.hostname or ""
+        
+        if not sp_domain:
+            sp_domain = "ecvcorp-my.sharepoint.com"  # fallback
+        
+        print(f"  Cookie domain: {sp_domain}")
+        
+        # 尝试作为 JSON 文件
+        if os.path.isfile(cookie_str):
+            with open(cookie_str, 'r', encoding='utf-8') as f:
+                data = json.load(f)
+            
+            # Cookie-Editor 导出格式: [{name, value, domain, ...}, ...]
+            if isinstance(data, list):
+                for c in data:
+                    name = c.get('name', '')
+                    value = c.get('value', '')
+                    domain = c.get('domain', sp_domain)
+                    if domain.startswith('.'):
+                        domain = domain[1:]
+                    if name and value:
+                        self.session.cookies.set(name, value, domain=domain)
+                print(f"  从 JSON 加载了 {len(data)} 个 cookies")
+            # Netscape 格式或其他 dict 格式
+            elif isinstance(data, dict):
+                for name, value in data.items():
+                    self.session.cookies.set(name, str(value), domain=sp_domain)
+                print(f"  从 JSON 加载了 {len(data)} 个 cookies")
+        else:
+            # Cookie header 格式: name1=value1; name2=value2
+            # 注意: FedAuth 值很长且可能包含 base64 的 = 和 +
+            # 只按 "; " (分号+空格) 分割,避免误切 base64 里的 =
+            parts = cookie_str.split('; ')
+            for part in parts:
+                part = part.strip()
+                if '=' in part:
+                    name, value = part.split('=', 1)
+                    self.session.cookies.set(name.strip(), value.strip(), domain=sp_domain)
+        
+        # 打印调试信息
+        cookie_names = [c.name for c in self.session.cookies]
+        print(f"  已设置 {len(cookie_names)} 个 cookies: {', '.join(cookie_names)}")
+        
+        has_fedauth = any(c.name == 'FedAuth' for c in self.session.cookies)
+        has_rtfa = any(c.name == 'rtFa' for c in self.session.cookies)
+        if not has_fedauth:
+            print("  [警告] 缺少 FedAuth cookie - 这是 SharePoint 的主要认证 cookie")
+        if not has_rtfa:
+            print("  [警告] 缺少 rtFa cookie")
+    
+    def _svc_headers(self) -> dict:
+        """mediap.svc.ms 请求的 headers"""
+        h = {}
+        if self.pac_token:
+            h["x-spopactoken"] = self.pac_token
+        if self.origin:
+            h["Origin"] = self.origin
+        if self.referer:
+            h["Referer"] = self.referer
+        return h
+    
+    def _sp_headers(self) -> dict:
+        """SharePoint segment 下载的 headers
+        
+        segment URL 通过 P1/P4 签名参数自认证,不需要额外 token。
+        浏览器请求也没有 cookie 或 Authorization header。
+        """
+        h = {}
+        if self.origin:
+            h["Origin"] = self.origin
+        if self.referer:
+            h["Referer"] = self.referer
+        return h
+    
+    def fetch_manifest(self, quiet: bool = False) -> dict:
+        """获取并解析 DASH manifest"""
+        if not quiet:
+            print("[1/5] 获取 DASH manifest...")
+        resp = self.session.get(self.manifest_url, headers=self._svc_headers())
+        if resp.status_code == 401:
+            print(f"\n[错误] 401 Unauthorized - 认证失败")
+            print("  可能原因:")
+            print("  1. 复制了 OPTIONS preflight 请求 (没有 x-spopactoken)")
+            print("  2. Token 已过期 (需要重新从浏览器复制)")
+            print("  3. 没有访问权限")
+            print(f"\n  当前 PAC Token: {'有' if self.pac_token else '无'}")
+            if self.pac_token:
+                print(f"  Token 前缀: {self.pac_token[:40]}...")
+            sys.exit(1)
+        resp.raise_for_status()
+        
+        xml_text = resp.text
+        self.manifest_info = parse_manifest(xml_text)
+        
+        dur = self.manifest_info["duration_sec"]
+        h, m, s = int(dur // 3600), int((dur % 3600) // 60), dur % 60
+        if not quiet:
+            print(f"  时长: {h:02d}:{m:02d}:{s:05.2f}")
+            print(f"  BaseURL: {self.manifest_info['base_url'][:80]}...")
+            
+            for track in self.manifest_info["tracks"]:
+                t = track["type"]
+                label = f" ({track['label']})" if track["label"] else ""
+                segs = len(track["segments"])
+                res = f" {track['width']}x{track['height']}" if track["width"] else ""
+                reps = ", ".join(r["id"] for r in track["representations"])
+                print(f"  Track {track['id']}: {t}{label}{res} | {segs} segments | reps: {reps}")
+        
+        # 保存 manifest
+        os.makedirs(self.output_dir, exist_ok=True)
+        with open(os.path.join(self.output_dir, "manifest.mpd"), "w", encoding="utf-8") as f:
+            f.write(xml_text)
+        
+        return self.manifest_info
+    
+    def _renew_manifest(self, track_type: str) -> dict:
+        """重新获取 manifest 以刷新签名 URL"""
+        print("  [续签] 重新获取 manifest...")
+        self.fetch_manifest(quiet=True)
+        # 返回对应类型的 track
+        for track in self.manifest_info["tracks"]:
+            if track["type"] == track_type:
+                if track_type == "audio" and track["label"] != "OriginalAudio":
+                    continue
+                return track
+        # fallback
+        for track in self.manifest_info["tracks"]:
+            if track["type"] == track_type:
+                return track
+        return None
+    
+    def fetch_encryption_key(self, track: dict) -> bytes:
+        """Step 2: 获取 AES-128 解密密钥"""
+        if not track["key_url"]:
+            print("  [跳过] 无加密")
+            return None
+        
+        print("[2/5] 获取解密密钥...")
+        resp = self.session.get(track["key_url"], headers=self._svc_headers())
+        resp.raise_for_status()
+        key = resp.content
+        print(f"  Key: {key.hex()} ({len(key)} bytes)")
+        
+        # 解析 IV
+        iv_str = track["iv"]
+        if iv_str.startswith("0x"):
+            iv_str = iv_str[2:]
+        self.encryption_iv = bytes.fromhex(iv_str)
+        self.encryption_key = key
+        print(f"  IV:  {self.encryption_iv.hex()}")
+        
+        return key
+    
+    def decrypt_segment(self, data: bytes) -> bytes:
+        """AES-128-CBC 解密"""
+        if not self.encryption_key or AES is None:
+            return data
+        cipher = AES.new(self.encryption_key, AES.MODE_CBC, self.encryption_iv)
+        decrypted = cipher.decrypt(data)
+        # PKCS7 unpadding
+        if len(decrypted) > 0:
+            pad = decrypted[-1]
+            if 0 < pad <= 16 and decrypted[-pad:] == bytes([pad]) * pad:
+                decrypted = decrypted[:-pad]
+        return decrypted
+    
+    def download_track(self, track: dict, track_name: str, max_workers: int = 4) -> str:
+        """下载一个 track 的所有 segments 并合并
+        
+        SharePoint 用 FedAuth/rtFa HttpOnly cookie 认证 segment 请求。
+        没有 cookie 时,URL 签名只能支撑约 50-70 个请求。
+        有 cookie 时可以全速下载。
+        """
+        import time as _time
+        from concurrent.futures import ThreadPoolExecutor, as_completed
+        
+        base_url = self.manifest_info["base_url"]
+        rep = track["representations"][0]
+        rep_id = rep["id"]
+        segments = track["segments"]
+        total = len(segments)
+        has_cookies = len(self.session.cookies) > 0
+        
+        out_path = os.path.join(self.output_dir, f"{track_name}.mp4")
+        
+        # 下载 init segment
+        init_url = resolve_url(base_url, track["init_tpl"], rep_id)
+        print(f"  下载 init segment...")
+        
+        # 调试: 检查 cookie 是否会被发送到这个 URL
+        if has_cookies:
+            matched = self.session.cookies.get_dict(domain=urlparse(init_url).hostname)
+            print(f"  [调试] 匹配到 {len(matched)} 个 cookies for {urlparse(init_url).hostname}")
+            if not matched:
+                print(f"  [调试] Cookie jar 内容:")
+                for c in self.session.cookies:
+                    print(f"    {c.name} domain={c.domain} path={c.path}")
+        
+        resp = self.session.get(init_url, headers=self._sp_headers())
+        
+        # 调试: 检查实际发送的 cookie header
+        if has_cookies:
+            sent_cookie = resp.request.headers.get('Cookie', '')
+            if sent_cookie:
+                print(f"  [调试] 实际发送 Cookie: {sent_cookie[:80]}...")
+            else:
+                print(f"  [调试] 未发送任何 Cookie!")
+        
+        if resp.status_code == 401:
+            print(f"  init segment 401")
+            if not has_cookies:
+                print("  [提示] 缺少 FedAuth cookie,请用 -c 参数提供")
+                print("  在浏览器 Console 执行: copy(document.cookie)")
+                print("  然后: python sp_video_dl.py curl_command.txt -c \"粘贴的cookie\"")
+            sys.exit(1)
+        resp.raise_for_status()
+        init_data = self.decrypt_segment(resp.content)
+        
+        print(f"  下载 {total} 个 media segments...")
+        
+        if has_cookies:
+            # 有 cookie,可以并发下载
+            workers = max_workers
+            print(f"  模式: 并发 (workers={workers})")
+        else:
+            # 没有 cookie,串行 + 限速
+            workers = 1
+            print(f"  模式: 串行 (无 cookie,可能在 ~50 个后 401)")
+        
+        seg_data_map = {}
+        
+        def dl_one(idx, seg_time):
+            url = resolve_url(base_url, track["media_tpl"], rep_id, seg_time)
+            for attempt in range(3):
+                r = self.session.get(url, headers=self._sp_headers())
+                if r.status_code == 200:
+                    return idx, self.decrypt_segment(r.content)
+                elif r.status_code in (429, 503):
+                    _time.sleep(int(r.headers.get("Retry-After", 5)))
+                elif r.status_code == 401:
+                    _time.sleep(2)
+                else:
+                    r.raise_for_status()
+            # 最后一次
+            r = self.session.get(url, headers=self._sp_headers())
+            r.raise_for_status()
+            return idx, self.decrypt_segment(r.content)
+        
+        downloaded = 0
+        failed = 0
+        with ThreadPoolExecutor(max_workers=workers) as pool:
+            futures = {pool.submit(dl_one, i, t): i for i, t in enumerate(segments)}
+            for future in as_completed(futures):
+                try:
+                    idx, data = future.result()
+                    seg_data_map[idx] = data
+                    downloaded += 1
+                    if downloaded % 50 == 0 or downloaded == total:
+                        pct = downloaded / total * 100
+                        print(f"  进度: {downloaded}/{total} ({pct:.1f}%)")
+                except Exception as e:
+                    failed += 1
+                    err = str(e)
+                    if "401" in err and not has_cookies:
+                        # 取消剩余
+                        for f in futures:
+                            f.cancel()
+                        print(f"\n  已下载 {downloaded}/{total} 后遇到 401")
+                        print(f"  根本原因: 缺少 SharePoint 认证 cookie (FedAuth/rtFa)")
+                        print(f"  HAR 导出不包含 HttpOnly cookie,需要手动获取")
+                        print(f"\n  解决方案:")
+                        print(f"  1. 在浏览器 Console 执行: copy(document.cookie)")
+                        print(f"  2. 运行: python sp_video_dl.py curl_command.txt -c \"粘贴\"")
+                        print(f"\n  如果 document.cookie 为空 (HttpOnly),安装浏览器扩展:")
+                        print(f"  - EditThisCookie 或 Cookie-Editor")
+                        print(f"  - 导出 ecvcorp-my.sharepoint.com 的所有 cookie 为 JSON")
+                        print(f"  - 运行: python sp_video_dl.py curl_command.txt -c cookies.json")
+                        sys.exit(1)
+                    if failed >= 5:
+                        for f in futures:
+                            f.cancel()
+                        print(f"\n[错误] 失败 {failed} 次: {err[:200]}")
+                        sys.exit(1)
+        
+        # 写入文件
+        print(f"  写入 {out_path}...")
+        with open(out_path, "wb") as f:
+            f.write(init_data)
+            for i in range(total):
+                if i in seg_data_map:
+                    f.write(seg_data_map[i])
+        
+        size_mb = os.path.getsize(out_path) / 1024 / 1024
+        print(f"  完成: {size_mb:.1f} MB")
+        return out_path
+    
+    def select_tracks(self) -> tuple:
+        """选择要下载的视频和音频 track"""
+        video_track = None
+        audio_track = None
+        
+        for track in self.manifest_info["tracks"]:
+            if track["type"] == "video" and video_track is None:
+                video_track = track
+            elif track["type"] == "audio":
+                # 优先选 OriginalAudio
+                if track["label"] == "OriginalAudio":
+                    audio_track = track
+                elif audio_track is None:
+                    audio_track = track
+        
+        return video_track, audio_track
+    
+    def fetch_transcript(self) -> str:
+        """下载转录/字幕文件
+        
+        流程:
+        1. 从 manifest URL 的 docid 参数提取 item API URL
+        2. 请求转录元数据获取转录列表
+        3. 下载转录内容 (实际为 WebVTT 格式)
+        4. 转换为 SRT 和纯文本
+        """
+        print("\n[额外] 下载转录...")
+        
+        parsed = urlparse(self.manifest_url)
+        qs = parse_qs(parsed.query)
+        docid = unquote(qs.get('docid', [''])[0])
+        
+        if not docid:
+            print("  [跳过] 无法从 manifest URL 提取 docid")
+            return None
+        
+        docid_parsed = urlparse(docid)
+        sp_host = f"{docid_parsed.scheme}://{docid_parsed.hostname}"
+        path = docid_parsed.path.replace('/_api/v2.0/', '/_api/v2.1/')
+        item_url = sp_host + path
+        
+        # Step 1: 获取转录元数据
+        meta_url = item_url + "?select=media%2Ftranscripts%2CaudioTracks&%24expand=media%2Ftranscripts%2Cmedia%2FaudioTracks"
+        print(f"  获取转录元数据...")
+        
+        resp = self.session.get(meta_url, headers=self._sp_headers())
+        if resp.status_code != 200:
+            print(f"  [跳过] 转录元数据请求返回 {resp.status_code}")
+            return None
+        
+        try:
+            meta = resp.json()
+        except Exception:
+            print(f"  [跳过] 转录元数据解析失败")
+            return None
+        
+        transcripts = meta.get('media', {}).get('transcripts', [])
+        if not transcripts:
+            print(f"  [跳过] 该视频没有转录")
+            return None
+        
+        print(f"  找到 {len(transcripts)} 个转录:")
+        for t in transcripts:
+            print(f"    - {t.get('displayName', '?')} ({t.get('languageTag', '?')}, {t.get('size', 0)} bytes)")
+        
+        transcript = next((t for t in transcripts if t.get('isDefault')), transcripts[0])
+        transcript_id = transcript.get('id', '')
+        
+        # Step 2: 下载转录内容
+        download_url = transcript.get('temporaryDownloadUrl', '')
+        if not download_url:
+            download_url = f"{item_url}/versions/current/media/transcripts/{transcript_id}/content"
+        
+        print(f"  下载转录内容...")
+        resp = self.session.get(download_url, headers=self._sp_headers())
+        if resp.status_code != 200:
+            print(f"  [跳过] 转录下载返回 {resp.status_code}")
+            return None
+        
+        # 确保正确的 UTF-8 编码
+        resp.encoding = 'utf-8-sig'  # 处理 BOM
+        content = resp.text
+        
+        os.makedirs(self.output_dir, exist_ok=True)
+        
+        # 检测格式
+        is_vtt = content.lstrip('\ufeff').startswith('WEBVTT')
+        
+        if is_vtt:
+            # 保存 VTT 原始文件
+            vtt_path = os.path.join(self.output_dir, "transcript.vtt")
+            with open(vtt_path, 'w', encoding='utf-8') as f:
+                f.write(content)
+            print(f"  已保存 VTT: {vtt_path}")
+            
+            # 解析 VTT 并转换
+            entries = self._parse_vtt(content)
+            if entries:
+                srt_path = os.path.join(self.output_dir, "transcript.srt")
+                txt_path = os.path.join(self.output_dir, "transcript.txt")
+                self._to_srt(entries, srt_path)
+                self._to_txt(entries, txt_path)
+                print(f"  已保存 SRT: {srt_path} ({len(entries)} 条)")
+                print(f"  已保存纯文本: {txt_path}")
+                return srt_path
+            return vtt_path
+        else:
+            # 尝试 JSON
+            raw_path = os.path.join(self.output_dir, "transcript.json")
+            with open(raw_path, 'w', encoding='utf-8') as f:
+                f.write(content)
+            print(f"  已保存原始转录: {raw_path}")
+            
+            try:
+                data = json.loads(content)
+                entries = data if isinstance(data, list) else data.get('entries', data.get('cues', []))
+                if not entries:
+                    for key in data:
+                        if isinstance(data[key], list) and len(data[key]) > 0:
+                            entries = data[key]
+                            break
+                if entries:
+                    srt_path = os.path.join(self.output_dir, "transcript.srt")
+                    txt_path = os.path.join(self.output_dir, "transcript.txt")
+                    self._to_srt(entries, srt_path)
+                    self._to_txt(entries, txt_path)
+                    print(f"  已保存 SRT: {srt_path}")
+                    print(f"  已保存纯文本: {txt_path}")
+                    return srt_path
+            except Exception as e:
+                print(f"  JSON 解析失败: {e}")
+            
+            return raw_path
+    
+    @staticmethod
+    def _parse_vtt(content: str) -> list:
+        """解析 WebVTT 格式为条目列表"""
+        entries = []
+        content = content.lstrip('\ufeff')  # 去 BOM
+        blocks = re.split(r'\n\n+', content)
+        
+        for block in blocks:
+            lines = block.strip().split('\n')
+            if len(lines) < 2:
+                continue
+            
+            # 找时间行 (HH:MM:SS.mmm --> HH:MM:SS.mmm)
+            time_line = None
+            text_start = 0
+            for j, line in enumerate(lines):
+                if '-->' in line:
+                    time_line = line
+                    text_start = j + 1
+                    break
+            
+            if not time_line:
+                continue
+            
+            # 解析时间
+            m = re.match(r'(\d{2}:\d{2}:\d{2}\.\d{3})\s*-->\s*(\d{2}:\d{2}:\d{2}\.\d{3})', time_line)
+            if not m:
+                continue
+            
+            start_str = m.group(1)
+            end_str = m.group(2)
+            
+            def vtt_to_ms(t):
+                parts = t.split(':')
+                h, mi = int(parts[0]), int(parts[1])
+                s_parts = parts[2].split('.')
+                s, ms = int(s_parts[0]), int(s_parts[1])
+                return h * 3600000 + mi * 60000 + s * 1000 + ms
+            
+            text_lines = lines[text_start:]
+            text = ' '.join(l.strip() for l in text_lines if l.strip())
+            
+            # 提取说话人 (格式: <v Speaker Name>text</v> 或直接文本)
+            speaker = ''
+            speaker_m = re.match(r'<v\s+([^>]+)>(.*?)(?:</v>)?$', text)
+            if speaker_m:
+                speaker = speaker_m.group(1).strip()
+                text = speaker_m.group(2).strip()
+            
+            entries.append({
+                'startTime': vtt_to_ms(start_str),
+                'endTime': vtt_to_ms(end_str),
+                'text': text,
+                'speakerName': speaker,
+            })
+        
+        return entries
+    
+    @staticmethod
+    def _ms_to_srt_time(ms: float) -> str:
+        """毫秒转 SRT 时间格式 HH:MM:SS,mmm"""
+        ms = int(ms)
+        h = ms // 3600000
+        m = (ms % 3600000) // 60000
+        s = (ms % 60000) // 1000
+        ms_rem = ms % 1000
+        return f"{h:02d}:{m:02d}:{s:02d},{ms_rem:03d}"
+    
+    def _to_srt(self, entries: list, path: str):
+        """转换为 SRT 字幕格式"""
+        with open(path, 'w', encoding='utf-8') as f:
+            for i, entry in enumerate(entries, 1):
+                # 支持多种字段名
+                start = entry.get('startTime', entry.get('start', entry.get('offset', 0)))
+                end = entry.get('endTime', entry.get('end', start + entry.get('duration', 0)))
+                text = entry.get('text', entry.get('value', entry.get('content', '')))
+                speaker = entry.get('speakerName', entry.get('speaker', entry.get('displayName', '')))
+                
+                # 时间可能是秒或毫秒
+                if isinstance(start, (int, float)) and start < 100000:
+                    start *= 1000  # 秒转毫秒
+                    end *= 1000
+                
+                start_str = self._ms_to_srt_time(start)
+                end_str = self._ms_to_srt_time(end)
+                
+                line = f"[{speaker}] {text}" if speaker else text
+                f.write(f"{i}\n{start_str} --> {end_str}\n{line}\n\n")
+    
+    def _to_txt(self, entries: list, path: str):
+        """转换为纯文本 (带说话人和时间戳)"""
+        with open(path, 'w', encoding='utf-8') as f:
+            last_speaker = ""
+            for entry in entries:
+                start = entry.get('startTime', entry.get('start', entry.get('offset', 0)))
+                text = entry.get('text', entry.get('value', entry.get('content', '')))
+                speaker = entry.get('speakerName', entry.get('speaker', entry.get('displayName', '')))
+                
+                if isinstance(start, (int, float)) and start < 100000:
+                    start *= 1000
+                
+                mins = int(start / 1000 / 60)
+                secs = int(start / 1000) % 60
+                
+                if speaker and speaker != last_speaker:
+                    f.write(f"\n[{speaker}] ({mins:02d}:{secs:02d})\n")
+                    last_speaker = speaker
+                
+                f.write(f"{text}\n")
+    
+    def run(self, max_workers: int = 4):
+        """完整下载流程"""
+        print("=" * 60)
+        print("SharePoint Stream 视频下载器")
+        print("=" * 60)
+        
+        # Step 1: Manifest
+        self.fetch_manifest()
+        
+        video_track, audio_track = self.select_tracks()
+        
+        if not video_track:
+            print("[错误] 未找到视频 track")
+            return
+        
+        # Step 2: 解密密钥
+        self.fetch_encryption_key(video_track)
+        
+        # Step 3: 下载视频
+        print(f"\n[3/5] 下载视频 track...")
+        video_path = self.download_track(video_track, "video", max_workers)
+        
+        # Step 4: 下载音频
+        audio_path = None
+        if audio_track:
+            print(f"\n[4/5] 下载音频 track...")
+            audio_path = self.download_track(audio_track, "audio", max_workers)
+        
+        # Step 5: 合并
+        print(f"\n[5/5] 合并音视频...")
+        final_path = os.path.join(self.output_dir, "final.mp4")
+        if audio_path:
+            ffmpeg_cmd = f'ffmpeg -y -i "{video_path}" -i "{audio_path}" -c copy "{final_path}"'
+            print(f"  执行: {ffmpeg_cmd}")
+            ret = os.system(ffmpeg_cmd)
+            if ret == 0:
+                print(f"  完成: {final_path}")
+                size_mb = os.path.getsize(final_path) / 1024 / 1024
+                print(f"  大小: {size_mb:.1f} MB")
+            else:
+                print(f"  ffmpeg 失败 (返回码 {ret})")
+                print(f"  视频和音频已分别保存,请手动合并:")
+                print(f"    {video_path}")
+                print(f"    {audio_path}")
+        else:
+            os.rename(video_path, final_path)
+            print(f"  完成: {final_path}")
+        
+        # Step 6: 下载转录
+        self.fetch_transcript()
+        
+        print("\n" + "=" * 60)
+        print("下载完成!")
+        print("=" * 60)
+
+
+# ============================================================
+# 入口
+# ============================================================
+
+def main():
+    parser = argparse.ArgumentParser(
+        description="SharePoint Stream 视频下载器",
+        epilog="用法: 从浏览器 DevTools 复制 videomanifest 请求的 cURL 命令"
+    )
+    parser.add_argument("input", nargs="?", help="包含 cURL 命令的文件路径")
+    parser.add_argument("-o", "--output", default="output", help="输出目录 (默认: output)")
+    parser.add_argument("-w", "--workers", type=int, default=4, help="并发下载线程数 (默认: 4)")
+    parser.add_argument("-t", "--token", help="driveAccessToken (如果 segment 下载 401,从浏览器 Console 获取)")
+    parser.add_argument("-c", "--cookie", help="Cookie 字符串或 JSON 文件路径 (从浏览器获取 FedAuth/rtFa)")
+    parser.add_argument("--dry-run", action="store_true", help="仅解析 manifest,不下载")
+    parser.add_argument("--transcript-only", action="store_true", help="仅下载转录文件")
+    args = parser.parse_args()
+    
+    # 获取 cURL 输入
+    curl_text = None
+    
+    if args.input:
+        with open(args.input, "r", encoding="utf-8") as f:
+            curl_text = f.read()
+    else:
+        print("请粘贴 videomanifest 请求的 cURL 命令 (粘贴完成后按两次回车):")
+        print("-" * 40)
+        lines = []
+        empty_count = 0
+        while True:
+            try:
+                line = input()
+                if line.strip() == "":
+                    empty_count += 1
+                    if empty_count >= 2:
+                        break
+                    lines.append(line)
+                else:
+                    empty_count = 0
+                    lines.append(line)
+            except EOFError:
+                break
+        curl_text = "\n".join(lines)
+    
+    if not curl_text or not curl_text.strip():
+        print("[错误] 未提供输入")
+        sys.exit(1)
+    
+    # 解析
+    curl_text = curl_text.strip()
+    if curl_text.lower().startswith("curl"):
+        parsed = parse_curl(curl_text)
+    elif curl_text.startswith("http"):
+        parsed = parse_raw_headers(curl_text)
+    else:
+        # 尝试两种方式
+        try:
+            parsed = parse_curl(curl_text)
+        except Exception:
+            parsed = parse_raw_headers(curl_text)
+    
+    if not parsed.get("url"):
+        print("[错误] 无法解析 URL")
+        sys.exit(1)
+    
+    # ============================================================
+    # 检测 OPTIONS preflight 请求 (最常见的错误)
+    # ============================================================
+    is_preflight = False
+    method = parsed.get("method", "GET")
+    has_acr_headers = any(
+        k.lower() == "access-control-request-headers" 
+        for k in parsed["headers"]
+    )
+    has_pac_token = any(
+        k.lower() == "x-spopactoken" 
+        for k in parsed["headers"]
+    )
+    
+    if method == "OPTIONS" or has_acr_headers:
+        is_preflight = True
+    
+    if is_preflight:
+        print("\n" + "=" * 60)
+        print("[错误] 你复制的是 OPTIONS preflight 请求,不是实际的 GET 请求!")
+        print("=" * 60)
+        print("""
+这是浏览器 CORS 预检请求,里面没有认证 token,无法下载。
+
+请按以下步骤重新复制:
+
+  1. 打开 DevTools -> Network 标签
+  2. 在 Filter 框输入 "videomanifest"
+  3. 你会看到两个同名请求:
+     - 一个 Method 是 OPTIONS (预检) <- 不要复制这个
+     - 一个 Method 是 GET (实际请求) <- 复制这个!
+  4. 右键点击 GET 那个请求 -> Copy -> Copy as cURL (bash)
+  
+  如何区分:
+  - OPTIONS 请求的 headers 里有 "access-control-request-headers"
+  - GET 请求的 headers 里有 "x-spopactoken: v1.eyJ..." (一个很长的 token)
+""")
+        sys.exit(1)
+    
+    if not has_pac_token:
+        print("\n[警告] 未找到 x-spopactoken header,请求可能会 401")
+        print("  确保复制的是 GET 请求 (不是 OPTIONS preflight)")
+    
+    print(f"\n解析到 URL: {parsed['url'][:100]}...")
+    print(f"Headers: {len(parsed['headers'])} 个")
+    for k in sorted(parsed["headers"].keys()):
+        v = parsed["headers"][k]
+        if len(v) > 60:
+            v = v[:60] + "..."
+        print(f"  {k}: {v}")
+    
+    if "videomanifest" not in parsed["url"]:
+        print("\n[警告] URL 中不包含 'videomanifest',可能不是正确的请求")
+        resp = input("是否继续? (y/N): ")
+        if resp.lower() != "y":
+            sys.exit(0)
+    
+    # 下载
+    downloader = SPVideoDownloader(
+        manifest_url=parsed["url"],
+        headers=parsed["headers"],
+        output_dir=args.output,
+        cookies=args.cookie,
+    )
+    
+    # 设置 driveAccessToken
+    if args.token:
+        token = args.token.strip()
+        if token.startswith("access_token="):
+            token = token[len("access_token="):]
+        downloader.sp_access_token = token
+        print(f"\n已设置 driveAccessToken: {token[:40]}...")
+    
+    if args.dry_run:
+        downloader.fetch_manifest()
+        print("\n[DRY RUN] 仅解析 manifest,不下载")
+    elif args.transcript_only:
+        downloader.fetch_transcript()
+    else:
+        downloader.run(max_workers=args.workers)
+
+
+if __name__ == "__main__":
+    main()