Refactor Google Meet Transcripts Extension for Local Use
- Removed all cloud-related functionalities, including login prompts and token handling. - Disabled Laxis cloud features, ensuring no data is sent to external servers. - Updated manifest to reflect the new local-only functionality. - Added a new Python server to handle transcripts locally, including WebSocket support. - Implemented storage management for transcripts, including deduplication and file writing. - Created a smoke test for the WebSocket server to simulate transcript updates. - Updated README with setup instructions and usage details for the new local server.
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
.venv/
|
||||
transcripts/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
@@ -0,0 +1,117 @@
|
||||
# meet-transcripts — سرور MCP زیرنویس Google Meet
|
||||
|
||||
سرور پایتونی که caption های Google Meet را از افزونهی Chrome میگیرد و آنها را بهصورت
|
||||
transcript تمیز و **SRT** به Claude (از طریق MCP) میدهد. در یک پراسس دو کار میکند:
|
||||
|
||||
```
|
||||
Google Meet (content script)
|
||||
│ chrome.runtime.sendMessage({type:"TRANSCRIPT_UPDATE", …})
|
||||
▼
|
||||
افزونه: service worker (bridge.js)
|
||||
│ WebSocket ws://127.0.0.1:8765
|
||||
▼
|
||||
ws_server.py ──▶ storage.py ──▶ transcripts/<sid>.{srt,txt,json}
|
||||
▲
|
||||
mcp_server.py (MCP stdio) ─────────┘ ──▶ Claude Desktop
|
||||
```
|
||||
|
||||
## ساختار
|
||||
|
||||
| فایل | کار |
|
||||
|------|-----|
|
||||
| `storage.py` | مدل داده، dedup (بر اساس `startedAt`)، رندر SRT/TXT، خواندن/نوشتن دیسک |
|
||||
| `ws_server.py` | سرور WebSocket؛ caption را از افزونه میگیرد و به storage میدهد |
|
||||
| `mcp_server.py` | **نقطهی ورود**؛ WebSocket + MCP را با هم اجرا میکند |
|
||||
| `_smoke_test.py` | تست بدون Chrome |
|
||||
|
||||
## اجرا (با venv)
|
||||
|
||||
```powershell
|
||||
cd meet-transcripts
|
||||
py -m venv .venv
|
||||
.\.venv\Scripts\Activate.ps1
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
```powershell
|
||||
python mcp_server.py # WebSocket + MCP (حالت عادی / برای Claude Desktop)
|
||||
python mcp_server.py --ws-only # فقط WebSocket
|
||||
python ws_server.py # فقط WebSocket (معادلِ بالا، برای تست دستی)
|
||||
```
|
||||
|
||||
> همهی لاگها روی **stderr** اند چون stdout برای پروتکل MCP (JSON-RPC) رزرو است.
|
||||
> برای تستِ دستیِ WebSocket از `ws_server.py` یا `--ws-only` استفاده کن (وگرنه پراسس
|
||||
> منتظر اتصال MCP روی stdin میماند).
|
||||
|
||||
## تست بدون Chrome
|
||||
|
||||
```powershell
|
||||
# ترمینال ۱:
|
||||
python ws_server.py
|
||||
# ترمینال ۲:
|
||||
python _smoke_test.py
|
||||
```
|
||||
|
||||
باید `PONG` + چند `ACK` بگیری و `transcripts/meeting-abc123.{srt,txt,json}` ساخته شود.
|
||||
|
||||
## اتصال به Claude Desktop (MCP)
|
||||
|
||||
به `claude_desktop_config.json` اضافه کن (مسیر را با مسیر واقعی عوض کن):
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"mcpServers": {
|
||||
"meet-transcripts": {
|
||||
"command": "C:\\...\\audio-voice-converter\\meet-transcripts\\.venv\\Scripts\\python.exe",
|
||||
"args": ["C:\\...\\audio-voice-converter\\meet-transcripts\\mcp_server.py"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Claude Desktop خودش `mcp_server.py` را اجرا میکند؛ همان پراسس WebSocket را هم بالا میآورد.
|
||||
اگر یک نمونهی دیگر از قبل پورت ۸۷۶۵ را گرفته باشد، این نمونه فقط MCP را سرو میکند و
|
||||
transcript ها را از روی دیسک میخواند (مشکلی پیش نمیآید).
|
||||
|
||||
### ابزارها / resourceها
|
||||
|
||||
| ابزار | کار |
|
||||
|-------|-----|
|
||||
| `list_sessions` | فهرست جلسهها + تعداد segment + آخرین خط |
|
||||
| `get_status(session_id?)` | **چک سبکِ تغییر** بدون متن: `latest_seq`, `count`, `updated_at` |
|
||||
| `get_updates(session_id?, after_seq)` | **خواندن افزایشی**: فقط segmentهای بعد از `after_seq` |
|
||||
| `get_latest_transcript()` | متن تمیزِ آخرین جلسه (با `[mm:ss]`) |
|
||||
| `get_transcript(session_id)` | متن تمیزِ یک جلسه |
|
||||
| `get_latest_srt()` / `get_srt(session_id)` | زیرنویس SRT |
|
||||
| `transcript://{id}` , `srt://{id}` | resourceها |
|
||||
|
||||
### خواندن افزایشی (بهجای خواندن کل transcript هر بار)
|
||||
|
||||
هر segment یک `seq` یکتا دارد. برای دنبالکردن یک جلسهی زنده بدون خواندن دوبارهی همهچیز:
|
||||
|
||||
۱. `get_status` بزن (خیلی ارزان، بدون متن). اگر `latest_seq`/`updated_at` عوض شد →
|
||||
۲. `get_updates(after_seq=<latest_seqِ قبلی>)` تا فقط موارد جدید + segmentِ درحالِتکمیل را بگیری.
|
||||
|
||||
## dedup و SRT — چهطور کار میکند
|
||||
|
||||
افزونه برای هر «حرف» چند بار پیام میفرستد (snapshotِ روبهرشد) ولی `startedAt` ثابت میماند.
|
||||
سرور با همین `startedAt` میفهمد اینها یک segmentاند و فقط کاملترین نسخه را نگه میدارد —
|
||||
پس متن تکراری ذخیره نمیشود. چون زمان شروع/پایان داریم، برای هر segment یک بلوک SRT با
|
||||
تایمکد ساخته میشود. برای هر جلسه: `.srt` (زیرنویس)، `.txt` (متن خوانا با `[mm:ss]`)،
|
||||
`.json` (دادهی خام برای resume).
|
||||
|
||||
## پروتکل پیامها
|
||||
|
||||
افزونه → سرور:
|
||||
|
||||
| پیام | توضیح |
|
||||
|------|-------|
|
||||
| `{type:"PING", ts}` | heartbeat هر ۲۰ ثانیه |
|
||||
| `{type:"TRANSCRIPT_UPDATE", sessionId, speaker, text, startedAt, endedAt}` | یک caption |
|
||||
|
||||
سرور → افزونه:
|
||||
|
||||
| پیام | توضیح |
|
||||
|------|-------|
|
||||
| `{type:"PONG", ts}` | پاسخ heartbeat |
|
||||
| `{type:"ACK", ok:true}` | تأیید دریافت |
|
||||
@@ -0,0 +1,37 @@
|
||||
"""تست سریع بدون Chrome: شبیهسازی چیزی که افزونه میفرستد (با startedAt/endedAt).
|
||||
|
||||
اول سرور را بالا بیاور: python ws_server.py
|
||||
بعد: python _smoke_test.py
|
||||
باید PONG + چند ACK بگیری و فایلهای transcripts/meeting-abc123.{srt,txt,json} ساخته شوند.
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
import websockets
|
||||
|
||||
|
||||
async def main():
|
||||
base = 1_700_000_000_000 # epoch ms
|
||||
async with websockets.connect("ws://127.0.0.1:8765") as ws:
|
||||
await ws.send(json.dumps({"type": "PING", "ts": 1}))
|
||||
print("PONG:", await ws.recv())
|
||||
|
||||
# حرف ۱: سه snapshotِ روبهرشد با startedAt ثابت → باید در یک segment جمع شوند
|
||||
for end, txt in [(1200, "سلام"), (2600, "سلام به همه"), (4000, "سلام به همه خوش آمدید")]:
|
||||
await ws.send(json.dumps({
|
||||
"type": "TRANSCRIPT_UPDATE", "sessionId": "meeting-abc123",
|
||||
"speaker": "Arash", "text": txt,
|
||||
"startedAt": base, "endedAt": base + end,
|
||||
}))
|
||||
print("ACK:", await ws.recv())
|
||||
|
||||
# حرف ۲: speaker دیگر، startedAt جدید
|
||||
for end, txt in [(7000, "ممنون"), (8500, "ممنون از دعوت")]:
|
||||
await ws.send(json.dumps({
|
||||
"type": "TRANSCRIPT_UPDATE", "sessionId": "meeting-abc123",
|
||||
"speaker": "Vahid", "text": txt,
|
||||
"startedAt": base + 6000, "endedAt": base + end,
|
||||
}))
|
||||
print("ACK:", await ws.recv())
|
||||
|
||||
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,155 @@
|
||||
"""
|
||||
mcp_server.py — نقطهی ورود. WebSocket و MCP را با هم در یک پراسس اجرا میکند.
|
||||
|
||||
- ws_server.run_forever() بهعنوان background task (گرفتن caption از افزونه)
|
||||
- FastMCP روی stdio (تا Claude Desktop transcript را بخواند)
|
||||
|
||||
هر دو حافظه/فایلهای مشترکِ storage را میبینند.
|
||||
|
||||
اجرا:
|
||||
pip install -r requirements.txt
|
||||
python mcp_server.py # WebSocket + MCP (برای Claude Desktop)
|
||||
python mcp_server.py --ws-only # فقط WebSocket (یا مستقیم: python ws_server.py)
|
||||
|
||||
کانفیگ Claude Desktop به همین فایل اشاره میکند (پایین README).
|
||||
نکته: stdout برای پروتکل MCP رزرو است؛ همهی لاگها در storage.log به stderr میروند.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
|
||||
|
||||
|
||||
|
||||
import storage
|
||||
from storage import (
|
||||
log, safe_session_id, latest_session, read_txt, read_srt,
|
||||
read_segments, json_mtime, fmt_clock, list_session_files,
|
||||
)
|
||||
import ws_server
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MCP
|
||||
# ---------------------------------------------------------------------------
|
||||
def build_mcp() -> "FastMCP":
|
||||
mcp = FastMCP("meet-transcripts")
|
||||
|
||||
@mcp.tool()
|
||||
def list_sessions() -> str:
|
||||
"""فهرست جلسههای ضبطشده (از روی دیسک): تعداد segment و آخرین خط."""
|
||||
files = list_session_files()
|
||||
if not files:
|
||||
return "هیچ جلسهای هنوز ضبط نشده است."
|
||||
latest = latest_session()
|
||||
out = []
|
||||
for p in files:
|
||||
lines = p.read_text(encoding="utf-8").splitlines()
|
||||
last = lines[-1] if lines else ""
|
||||
mark = " ← آخرین" if p.stem == latest else ""
|
||||
out.append(f"- {p.stem}{mark}: {len(lines)} segment | آخرین: {last[:80]}")
|
||||
return "\n".join(out)
|
||||
|
||||
@mcp.tool()
|
||||
def get_status(session_id: str = "") -> str:
|
||||
"""وضعیت سبک برای «چیزی تغییر کرد؟» بدون خواندن متن. اگر session_id ندهی، آخرین جلسه.
|
||||
خروجی JSON: latest_seq، count، live_len، updated_at. اگر latest_seq/updated_at عوض شد،
|
||||
get_updates بزن."""
|
||||
sid = safe_session_id(session_id) if session_id else latest_session()
|
||||
if not sid:
|
||||
return json.dumps({"session_id": None, "latest_seq": 0, "count": 0,
|
||||
"live_len": 0, "updated_at": 0}, ensure_ascii=False)
|
||||
segs = read_segments(sid)
|
||||
return json.dumps({
|
||||
"session_id": sid,
|
||||
"latest_seq": segs[-1]["seq"] if segs else 0,
|
||||
"count": len(segs),
|
||||
"live_len": len(segs[-1].get("text", "")) if segs else 0,
|
||||
"updated_at": round(json_mtime(sid), 3),
|
||||
}, ensure_ascii=False)
|
||||
|
||||
@mcp.tool()
|
||||
def get_updates(session_id: str = "", after_seq: int = 0) -> str:
|
||||
"""خواندن افزایشی: فقط segmentهای از after_seq به بعد (نه کل transcript).
|
||||
بار اول after_seq=0 (همه را میگیری). دفعات بعد latest_seqِ قبلی را بده تا فقط
|
||||
موارد جدید + segmentِ زنده را بگیری. خروجی JSON: {latest_seq, count, segments:[{seq,t,speaker,text}]}."""
|
||||
sid = safe_session_id(session_id) if session_id else latest_session()
|
||||
if not sid:
|
||||
return json.dumps({"session_id": None, "latest_seq": 0, "count": 0,
|
||||
"segments": []}, ensure_ascii=False)
|
||||
segs = read_segments(sid)
|
||||
base = min((s["start"] for s in segs), default=0)
|
||||
sel = [s for s in segs if s.get("seq", 0) >= after_seq] if after_seq > 0 else segs
|
||||
out = [{"seq": s.get("seq", 0), "t": fmt_clock(s["start"] - base),
|
||||
"speaker": s.get("speaker", ""), "text": (s.get("text") or "").strip()}
|
||||
for s in sel]
|
||||
return json.dumps({
|
||||
"session_id": sid,
|
||||
"latest_seq": segs[-1]["seq"] if segs else 0,
|
||||
"count": len(segs),
|
||||
"segments": out,
|
||||
}, ensure_ascii=False)
|
||||
|
||||
@mcp.tool()
|
||||
def get_latest_transcript() -> str:
|
||||
"""متن تمیزِ آخرین جلسه (با [mm:ss]، بدون تکرار)."""
|
||||
sid = latest_session()
|
||||
if not sid:
|
||||
return "هنوز هیچ caption ای دریافت نشده است."
|
||||
return read_txt(sid) or ""
|
||||
|
||||
@mcp.tool()
|
||||
def get_transcript(session_id: str) -> str:
|
||||
"""متن تمیزِ یک جلسه با sessionId (با [mm:ss])."""
|
||||
txt = read_txt(session_id)
|
||||
return txt if txt is not None else f"جلسهای با id «{safe_session_id(session_id)}» نیست."
|
||||
|
||||
@mcp.tool()
|
||||
def get_latest_srt() -> str:
|
||||
"""زیرنویس SRT آخرین جلسه (با تایمکد استاندارد)."""
|
||||
sid = latest_session()
|
||||
if not sid:
|
||||
return "هنوز هیچ caption ای دریافت نشده است."
|
||||
return read_srt(sid) or ""
|
||||
|
||||
@mcp.tool()
|
||||
def get_srt(session_id: str) -> str:
|
||||
"""زیرنویس SRT یک جلسه با sessionId."""
|
||||
srt = read_srt(session_id)
|
||||
return srt if srt is not None else f"جلسهای با id «{safe_session_id(session_id)}» نیست."
|
||||
|
||||
@mcp.resource("transcript://{session_id}")
|
||||
def transcript_resource(session_id: str) -> str:
|
||||
return read_txt(session_id) or ""
|
||||
|
||||
@mcp.resource("srt://{session_id}")
|
||||
def srt_resource(session_id: str) -> str:
|
||||
return read_srt(session_id) or ""
|
||||
|
||||
return mcp
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# اجرا
|
||||
# ---------------------------------------------------------------------------
|
||||
async def main():
|
||||
asyncio.create_task(ws_server.run_forever())
|
||||
log(f"📁 transcripts در: {storage.TRANSCRIPTS_DIR}")
|
||||
|
||||
ws_only = "--ws-only" in sys.argv
|
||||
if FastMCP is not None and not ws_only:
|
||||
log("🔗 MCP server (stdio) فعال است — منتظر اتصال Claude Desktop …")
|
||||
await build_mcp().run_stdio_async()
|
||||
else:
|
||||
if FastMCP is None and not ws_only:
|
||||
log("ℹ️ پکیج mcp نصب نیست؛ فقط WebSocket اجرا میشود (pip install mcp).")
|
||||
await asyncio.Future()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
asyncio.run(main())
|
||||
except KeyboardInterrupt:
|
||||
log("\n👋 خاموش شد.")
|
||||
@@ -0,0 +1,2 @@
|
||||
websockets>=12.0
|
||||
mcp>=1.2.0
|
||||
@@ -0,0 +1,234 @@
|
||||
"""
|
||||
storage.py — مدل داده، dedup، و خواندن/نوشتن transcript روی دیسک.
|
||||
|
||||
این ماژول هیچ I/O شبکهای ندارد؛ فقط منطقِ segment و فایلها:
|
||||
- segmentهای هر جلسه را نگه میدارد (با dedup بر اساس startedAt)
|
||||
- برای هر جلسه سه فایل مینویسد: <sid>.srt / <sid>.txt / <sid>.json
|
||||
- توابع خواندن برای لایهی MCP فراهم میکند
|
||||
|
||||
چرا segmentمحور با dedup؟ افزونه برای هر «حرف» چند بار پیام میفرستد و هر بار متن
|
||||
کمی بلندتر میشود (snapshotِ روبهرشد) ولی startedAt ثابت میماند. پس startedAt کلیدِ
|
||||
هویتِ segment است: تکرار شد → همان segment بهروز میشود (نه append). این هم تکرار را
|
||||
حذف میکند و هم چون startedAt/endedAt داریم، زمانبندیِ SRT دقیق میشود.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
TRANSCRIPTS_DIR = Path(__file__).parent / "transcripts"
|
||||
TRANSCRIPTS_DIR.mkdir(exist_ok=True)
|
||||
|
||||
# segmentهای هر جلسه در حافظه: { sid: [ {seq, speaker, text, start, end, start_key}, ... ] }
|
||||
sessions_segments: "dict[str, list[dict]]" = {}
|
||||
|
||||
|
||||
def log(*args) -> None:
|
||||
"""همهی لاگها به stderr میروند؛ stdout برای پروتکل MCP رزرو است."""
|
||||
print(*args, file=sys.stderr, flush=True)
|
||||
|
||||
|
||||
def safe_session_id(session_id: str) -> str:
|
||||
cleaned = re.sub(r"[^a-zA-Z0-9_-]", "", session_id or "")
|
||||
return cleaned or "default"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# زمان و رندر
|
||||
# ---------------------------------------------------------------------------
|
||||
def to_epoch_s(v) -> "float | None":
|
||||
"""startedAt/endedAt را به ثانیهی epoch تبدیل میکند (epoch ms، رشتهی عددی یا ISO)."""
|
||||
if v is None or isinstance(v, bool):
|
||||
return None
|
||||
if isinstance(v, (int, float)):
|
||||
return v / 1000.0 if v > 1e11 else float(v)
|
||||
if isinstance(v, str):
|
||||
s = v.strip()
|
||||
if not s:
|
||||
return None
|
||||
if s.isdigit():
|
||||
n = float(s)
|
||||
return n / 1000.0 if n > 1e11 else n
|
||||
try:
|
||||
from datetime import datetime
|
||||
return datetime.fromisoformat(s.replace("Z", "+00:00")).timestamp()
|
||||
except ValueError:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def is_continuation(prev: str, cur: str) -> bool:
|
||||
"""fallback وقتی timestamp نداریم: آیا cur ادامه/سوپرستِ همان حرفِ prev است؟"""
|
||||
if not prev or not cur:
|
||||
return False
|
||||
short, long = (prev, cur) if len(prev) <= len(cur) else (cur, prev)
|
||||
common = 0
|
||||
for a, b in zip(short, long):
|
||||
if a == b:
|
||||
common += 1
|
||||
else:
|
||||
break
|
||||
return common >= max(8, int(len(short) * 0.7))
|
||||
|
||||
|
||||
def _fmt_srt(seconds: float) -> str:
|
||||
if seconds < 0:
|
||||
seconds = 0
|
||||
ms = int(round(seconds * 1000))
|
||||
h, ms = divmod(ms, 3600000)
|
||||
m, ms = divmod(ms, 60000)
|
||||
s, ms = divmod(ms, 1000)
|
||||
return f"{h:02d}:{m:02d}:{s:02d},{ms:03d}"
|
||||
|
||||
|
||||
def fmt_clock(seconds: float) -> str:
|
||||
if seconds < 0:
|
||||
seconds = 0
|
||||
total = int(seconds)
|
||||
h, rem = divmod(total, 3600)
|
||||
m, s = divmod(rem, 60)
|
||||
return f"{h:02d}:{m:02d}:{s:02d}" if h else f"{m:02d}:{s:02d}"
|
||||
|
||||
|
||||
def render_srt(segments: "list[dict]") -> str:
|
||||
if not segments:
|
||||
return ""
|
||||
base = min(seg["start"] for seg in segments)
|
||||
out = []
|
||||
for i, seg in enumerate(segments, 1):
|
||||
start = seg["start"] - base
|
||||
end = max(seg["end"], seg["start"]) - base
|
||||
if end <= start:
|
||||
end = start + 1.0 # حداقل ۱ ثانیه نمایش
|
||||
speaker = (seg.get("speaker") or "").strip()
|
||||
text = (seg.get("text") or "").strip()
|
||||
line = f"{speaker}: {text}" if speaker else text
|
||||
out.append(f"{i}\n{_fmt_srt(start)} --> {_fmt_srt(end)}\n{line}\n")
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
def render_txt(segments: "list[dict]") -> str:
|
||||
if not segments:
|
||||
return ""
|
||||
base = min(seg["start"] for seg in segments)
|
||||
lines = []
|
||||
for seg in segments:
|
||||
ts = fmt_clock(seg["start"] - base)
|
||||
speaker = (seg.get("speaker") or "").strip()
|
||||
text = (seg.get("text") or "").strip()
|
||||
lines.append(f"[{ts}] {speaker}: {text}" if speaker else f"[{ts}] {text}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# نوشتن / بهروزرسانی
|
||||
# ---------------------------------------------------------------------------
|
||||
def ensure_loaded(sid: str) -> "list[dict]":
|
||||
"""segmentهای جلسه را میدهد؛ اگر در حافظه نبود از <sid>.json میخواند (resume)."""
|
||||
if sid not in sessions_segments:
|
||||
p = TRANSCRIPTS_DIR / f"{sid}.json"
|
||||
if p.exists():
|
||||
try:
|
||||
sessions_segments[sid] = json.loads(p.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
sessions_segments[sid] = []
|
||||
else:
|
||||
sessions_segments[sid] = []
|
||||
return sessions_segments[sid]
|
||||
|
||||
|
||||
def persist(sid: str) -> None:
|
||||
segs = sessions_segments.get(sid, [])
|
||||
(TRANSCRIPTS_DIR / f"{sid}.json").write_text(
|
||||
json.dumps(segs, ensure_ascii=False), encoding="utf-8")
|
||||
(TRANSCRIPTS_DIR / f"{sid}.srt").write_text(render_srt(segs), encoding="utf-8")
|
||||
(TRANSCRIPTS_DIR / f"{sid}.txt").write_text(render_txt(segs), encoding="utf-8")
|
||||
|
||||
|
||||
def upsert_segment(session_id, speaker, text, started_at, ended_at) -> None:
|
||||
"""یک caption را اضافه یا (اگر همان حرف باشد) بهروز میکند، سپس روی دیسک مینویسد."""
|
||||
sid = safe_session_id(session_id)
|
||||
text = (text or "").strip()
|
||||
if not text:
|
||||
return
|
||||
segs = ensure_loaded(sid)
|
||||
|
||||
start_s = to_epoch_s(started_at)
|
||||
end_s = to_epoch_s(ended_at)
|
||||
recv = time.time()
|
||||
|
||||
matched = False
|
||||
if segs:
|
||||
last = segs[-1]
|
||||
if last.get("speaker") == speaker:
|
||||
lk = last.get("start_key")
|
||||
if start_s is not None and lk is not None:
|
||||
matched = abs(lk - start_s) < 0.001
|
||||
elif start_s is None and lk is None:
|
||||
matched = is_continuation(last.get("text", ""), text)
|
||||
if matched:
|
||||
last = segs[-1]
|
||||
last["text"] = text # جدیدترین snapshot کاملترین است
|
||||
last["end"] = end_s if end_s is not None else recv
|
||||
else:
|
||||
seq = (segs[-1]["seq"] + 1) if segs else 1
|
||||
segs.append({
|
||||
"seq": seq,
|
||||
"speaker": speaker,
|
||||
"text": text,
|
||||
"start": start_s if start_s is not None else recv,
|
||||
"end": end_s if end_s is not None else (start_s if start_s is not None else recv),
|
||||
"start_key": start_s,
|
||||
})
|
||||
persist(sid)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# خواندن (برای لایهی MCP)
|
||||
# ---------------------------------------------------------------------------
|
||||
def _read(sid: str, ext: str) -> "str | None":
|
||||
p = TRANSCRIPTS_DIR / f"{safe_session_id(sid)}.{ext}"
|
||||
if p.exists():
|
||||
try:
|
||||
return p.read_text(encoding="utf-8")
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def read_txt(sid: str) -> "str | None":
|
||||
return _read(sid, "txt")
|
||||
|
||||
|
||||
def read_srt(sid: str) -> "str | None":
|
||||
return _read(sid, "srt")
|
||||
|
||||
|
||||
def read_segments(sid: str) -> "list[dict]":
|
||||
p = TRANSCRIPTS_DIR / f"{safe_session_id(sid)}.json"
|
||||
if p.exists():
|
||||
try:
|
||||
return json.loads(p.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return []
|
||||
return []
|
||||
|
||||
|
||||
def json_mtime(sid: str) -> float:
|
||||
p = TRANSCRIPTS_DIR / f"{safe_session_id(sid)}.json"
|
||||
return p.stat().st_mtime if p.exists() else 0.0
|
||||
|
||||
|
||||
def latest_session() -> "str | None":
|
||||
files = list(TRANSCRIPTS_DIR.glob("*.txt"))
|
||||
if not files:
|
||||
return None
|
||||
return max(files, key=lambda p: p.stat().st_mtime).stem
|
||||
|
||||
|
||||
def list_session_files():
|
||||
"""فایلهای .txt جلسهها را بهترتیبِ جدیدترین برمیگرداند."""
|
||||
return sorted(TRANSCRIPTS_DIR.glob("*.txt"),
|
||||
key=lambda p: p.stat().st_mtime, reverse=True)
|
||||
@@ -0,0 +1,79 @@
|
||||
"""
|
||||
ws_server.py — سرور WebSocket که caption ها را از افزونهی Chrome میگیرد.
|
||||
|
||||
افزونه (service worker → bridge.js) هر caption را با این پیام میفرستد:
|
||||
{type:"TRANSCRIPT_UPDATE", sessionId, speaker, text, startedAt, endedAt}
|
||||
و heartbeat هم:
|
||||
{type:"PING", ts}
|
||||
|
||||
این فایل را میتوان مستقل اجرا کرد (فقط WebSocket، برای تست با _smoke_test.py):
|
||||
python ws_server.py
|
||||
ولی در حالت عادی mcp_server.py همین را بهعنوان background task بالا میآورد.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
import websockets
|
||||
|
||||
from storage import upsert_segment, log
|
||||
|
||||
HOST = "127.0.0.1"
|
||||
PORT = 8765
|
||||
|
||||
|
||||
async def handle_client(websocket):
|
||||
peer = getattr(websocket, "remote_address", "?")
|
||||
log(f"🔌 افزونه وصل شد: {peer}")
|
||||
try:
|
||||
async for raw in websocket:
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
log(f"⚠️ پیام نامعتبر: {raw!r}")
|
||||
continue
|
||||
|
||||
msg_type = data.get("type")
|
||||
|
||||
if msg_type == "PING":
|
||||
await websocket.send(json.dumps({"type": "PONG", "ts": data.get("ts")}))
|
||||
continue
|
||||
|
||||
if msg_type == "TRANSCRIPT_UPDATE":
|
||||
text = (data.get("text") or "").strip()
|
||||
if not text:
|
||||
continue
|
||||
session_id = data.get("sessionId", "default")
|
||||
speaker = data.get("speaker", "Unknown")
|
||||
upsert_segment(session_id, speaker, text,
|
||||
data.get("startedAt"), data.get("endedAt"))
|
||||
stamp = datetime.now().strftime("%H:%M:%S")
|
||||
log(f"[{stamp}] ({session_id[:8]}) {speaker}: {text[:60]}")
|
||||
await websocket.send(json.dumps({"type": "ACK", "ok": True}))
|
||||
continue
|
||||
|
||||
log(f"❓ نوع ناشناخته: {msg_type}")
|
||||
except websockets.ConnectionClosed:
|
||||
pass
|
||||
finally:
|
||||
log(f"❌ افزونه قطع شد: {peer}")
|
||||
|
||||
|
||||
async def run_forever():
|
||||
"""WebSocket را بالا میآورد. اگر پورت اشغال بود (نمونهی دیگری بالاست) graceful رد میشود."""
|
||||
try:
|
||||
async with websockets.serve(handle_client, HOST, PORT):
|
||||
log(f"🚀 WebSocket روی ws://{HOST}:{PORT} گوش میدهد …")
|
||||
await asyncio.Future()
|
||||
except OSError as e:
|
||||
log(f"⚠️ نتوانست ws://{HOST}:{PORT} را بگیرد ({e}).")
|
||||
log(" این نمونه فقط MCP را سرو میکند و transcript ها را از روی دیسک میخواند.")
|
||||
await asyncio.Future()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
asyncio.run(run_forever())
|
||||
except KeyboardInterrupt:
|
||||
log("\n👋 خاموش شد.")
|
||||
Reference in New Issue
Block a user