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:
vahidaskari
2026-06-12 00:31:32 +03:30
parent 602dcb7430
commit 7bc34c79ed
35 changed files with 1069 additions and 840 deletions
-1
View File
@@ -1 +0,0 @@
.qodo
-19
View File
@@ -1,19 +0,0 @@
"use strict"
import logger from './logger.js';
logger.debug("Hello, world from background!")
function setBadgeText(enabled) {
const text = enabled ? "ON" : "OFF"
void chrome.action.setBadgeText({text: text})
}
function startUp() {
chrome.storage.sync.get("slider", (data) => {
setBadgeText(!!data.slider)
})
}
// Ensure the background script always runs.
chrome.runtime.onStartup.addListener(startUp)
chrome.runtime.onInstalled.addListener(startUp)
-5
View File
@@ -1,5 +0,0 @@
var config = {
loggerLevel: 'debug', //change it to 'debug' for all logs, 'info' or more to reduce the log output in the production
defaultCaptureInterval: 100, //change it to 1000 for 1 second interval
};
export default config;
-64
View File
@@ -1,64 +0,0 @@
"use strict"
const blurFilter = "blur(6px)"
let textToBlur = ""
// Search this DOM node for text to blur and blur the parent element if found.
function processNode(node) {
if (node.childNodes.length > 0) {
Array.from(node.childNodes).forEach(processNode)
}
if (node.nodeType === Node.TEXT_NODE &&
node.textContent !== null && node.textContent.trim().length > 0) {
const parent = node.parentElement
if (parent !== null &&
(parent.tagName === 'SCRIPT' || parent.style.filter === blurFilter)) {
// Already blurred
return
}
if (node.textContent.includes(textToBlur)) {
blurElement(parent)
}
}
}
function blurElement(elem) {
elem.style.filter = blurFilter
console.debug("blurred id:" + elem.id + " class:" + elem.className +
" tag:" + elem.tagName + " text:" + elem.textContent)
}
// Create a MutationObserver to watch for changes to the DOM.
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.addedNodes.length > 0) {
mutation.addedNodes.forEach(processNode)
} else {
processNode(mutation.target)
}
})
})
// Enable the content script by default.
let enabled = true
const keys = ["slider", "item"]
chrome.storage.sync.get(keys, (data) => {
if (data.enabled === false) {
enabled = false
}
if (data.item) {
textToBlur = data.item
}
// Only start observing the DOM if the extension is enabled and there is text to blur.
if (enabled && textToBlur.trim().length > 0) {
observer.observe(document, {
attributes: false,
characterData: true,
childList: true,
subtree: true,
})
// Loop through all elements on the page for initial processing.
processNode(document)
}
})
-280
View File
@@ -1,280 +0,0 @@
"use strict";
(async () => {
// This file cannot import config.js or logger.js directly because it runs in a different context (source:https://stackoverflow.com/a/53033388/4235602)
// So we need to load them dynamically. But our config.js and logger.js are in the same directory and can import each other
try {
//#region load logger
const loggerSrc = chrome.runtime.getURL("logger.js");
const loggerModule = await import(loggerSrc);
const logger = loggerModule.default;
logger.debug("Logger initialized successfully");
logger.debug("Content script starting...");
//#endregion
//#region load config
const configSrc = chrome.runtime.getURL("config.js");
const configModule = await import(configSrc);
const config = configModule.default;
logger.debug("Config loaded:", config);
//#endregion
class CaptionCapture {
constructor() {
this.isCapturing = false;
this.intervalId = null;
this.lastSpans = [];
this.captionsLog = [];
this.mergedCaption = [];
this.subtitleText = null;
this.checkSubtitleInterval = config.defaultCaptureInterval;
}
// Function to detect and get the caption spans as an array
detectCaptionAsArray() {
try {
let spans = document.querySelectorAll('div.ygicle.VbkSUe');
if (!spans || spans.length === 0) {
logger.debug("No caption spans found");
return [];
}
return Array.from(spans).map(span => span.innerText.trim()) || [];
} catch (error) {
logger.error("Error detecting captions:", error);
return [];
}
}
// Function to get absolute time in HH:MM:SS,mmm format (Real-Time)
getCurrentTimestamp() {
let now = new Date();
let hours = String(now.getHours()).padStart(2, '0');
let minutes = String(now.getMinutes()).padStart(2, '0');
let seconds = String(now.getSeconds()).padStart(2, '0');
let millis = String(now.getMilliseconds()).padStart(3, '0');
return `${hours}:${minutes}:${seconds},${millis}`; // Absolute time format
}
// Function to save new words with timestamp
saveCaptionByWords(timestamp, newText) {
this.captionsLog.push({ time: timestamp, text: newText });
logger.debug(`${timestamp} --> ${newText}`); // Print in subtitle format
}
formatTimestamp(ms) {
const h = String(Math.floor(ms / 3600000)).padStart(2, "0");
ms %= 3600000;
const m = String(Math.floor(ms / 60000)).padStart(2, "0");
ms %= 60000;
const s = String(Math.floor(ms / 1000)).padStart(2, "0");
const msStr = String(ms % 1000).padStart(3, "0");
return `${h}:${m}:${s},${msStr}`;
}
mergeCaptions(captions, gap = 1000) {
if (!captions || captions.length === 0) return [];
const parseTime = (timestamp) => {
const [h, m, sMs] = timestamp.split(":");
const [s, ms] = sMs.split(",");
return (
parseInt(h) * 3600000 +
parseInt(m) * 60000 +
parseInt(s) * 1000 +
parseInt(ms)
);
};
const merged = [];
let current = {
start: parseTime(captions[0].time),
end: parseTime(captions[0].time),
texts: [captions[0].text]
};
for (let i = 1; i < captions.length; i++) {
const entry = captions[i];
const time = parseTime(entry.time);
const timeDiff = time - current.end;
if (timeDiff > gap) {
merged.push({
start: this.formatTimestamp(current.start),
end: this.formatTimestamp(current.end),
text: current.texts.join(" ")
});
current = {
start: time,
end: time,
texts: [entry.text]
};
} else {
current.end = time;
current.texts.push(entry.text);
}
}
// Push the last group
merged.push({
start: this.formatTimestamp(current.start),
end: this.formatTimestamp(current.end),
text: current.texts.join(" ")
});
this.mergedCaption = merged;
return merged;
}
printMergedSubtitle() {
const merged = this.mergeCaptions(this.getCapturedCaptions(), 1000);
if (merged.length === 0) {
logger.debug("No subtitles to print.");
return;
}
const parseTime = (timestamp) => {
const [h, m, sMs] = timestamp.split(":");
const [s, ms] = sMs.split(",");
return (
parseInt(h) * 3600000 +
parseInt(m) * 60000 +
parseInt(s) * 1000 +
parseInt(ms)
);
};
const baseTime = parseTime(merged[0].start); // time of first word
const toRelative = (absTime) => {
const ms = parseTime(absTime) - baseTime;
return this.formatTimestamp(ms);
};
this.subtitleText = merged.map((item, index) =>
`${index + 1}\n${toRelative(item.start)} --> ${toRelative(item.end)}\n${item.text}\n`
).join("\n");
logger.debug(this.subtitleText);
return this.subtitleText;
}
generateSrtFilename() {
const now = new Date();
const pad = (n) => String(n).padStart(2, '0');
return `meet_caption_${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}_${pad(now.getHours())}${pad(now.getMinutes())}.srt`;
}
downloadSrtFile() {
if (!this.subtitleText) {
logger.warn("No subtitle content found. Please call printMergedSubtitle() first.");
return;
}
const filename = this.generateSrtFilename();
const blob = new Blob([this.subtitleText], { type: "text/plain" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
// Function to start capturing captions
startCapturing() {
if (this.isCapturing) {
logger.debug("Caption capturing is already running.");
return;
}
this.isCapturing = true;
logger.debug("Caption capturing started.");
this.intervalId = setInterval(() => {
let currentSpans = this.detectCaptionAsArray();
let lastOldSpan = this.lastSpans[this.lastSpans.length - 1] || "";
let lastNewSpan = currentSpans[currentSpans.length - 1] || "";
let isLastCaptionChanged = lastNewSpan !== lastOldSpan;
if (isLastCaptionChanged) {
this.lastSpans = currentSpans; // Update stored spans
let timestamp = this.getCurrentTimestamp();
// Get only the new part of the last span
let newText = lastNewSpan.replace(lastOldSpan, "").trim();
if (newText) {
logger.debug(newText)
const wordCount = newText.trim().split(/\s+/).length;
const BurstTextCount = 80;
if (wordCount <= (this.checkSubtitleInterval / 100) * BurstTextCount) {
this.saveCaptionByWords(timestamp, newText);
} else {
logger.debug(`Skipped burst text with ${wordCount} words at ${timestamp}`);
}
}
}
}, this.checkSubtitleInterval); // Runs every 100ms for better precision
}
// Function to stop capturing captions
stopCapturing() {
if (!this.isCapturing) {
logger.debug("Caption capturing is not running.");
return;
}
this.isCapturing = false;
clearInterval(this.intervalId);
logger.debug("Caption capturing stopped.");
}
// Function to get the saved captions log
getCapturedCaptions() {
return this.captionsLog;
}
}
// Initialize CaptionCapture and message listener
const captionCapture = new CaptionCapture();
logger.debug("CaptionCapture initialized");
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
logger.debug("start of listener in content.js");
if (message.action === "startCapture") {
logger.debug("startCapture content.js");
captionCapture.startCapturing();
sendResponse({ status: "started" });
} else if (message.action === "stopCapture") {
logger.debug("stopCapture content.js");
captionCapture.stopCapturing();
sendResponse({ status: "stopped" });
} else if (message.action === "downloadSrt") {
alert("Downloading SRT file...");
logger.debug("downloadSrt content.js");
captionCapture.printMergedSubtitle();
captionCapture.downloadSrtFile();
sendResponse({ status: "downloadStarted" });
}
});
logger.debug("Message listener set up");
} catch (error) {
logger.error("Error loading modules:", error);
}
})();
-47
View File
@@ -1,47 +0,0 @@
import config from './config.js';
class Logger {
static LEVELS = {
debug: 0,
info: 1,
warn: 2,
error: 3,
none: 4
};
static currentLevel = Logger.LEVELS.debug;
static setLevel(level) {
if (level in Logger.LEVELS) {
Logger.currentLevel = Logger.LEVELS[level];
}
}
static shouldLog(level, instanceLevel) {
return Logger.LEVELS[level] >= instanceLevel;
}
constructor(level = 'debug', scope = '') {
this.level = Logger.LEVELS[level] ?? Logger.LEVELS.debug;
this.scope = scope.toUpperCase();
for (const levelName of Object.keys(Logger.LEVELS)) {
if (levelName === 'none') continue;
this[levelName] = (...args) => {
if (Logger.shouldLog(levelName, this.level) && typeof console[levelName] === 'function') {
console[levelName](
`[${levelName.toUpperCase()}]${this.scope ? ` [${this.scope}]` : ''}`,
...args
);
}
};
}
}
}
// Create a default global logger with no scope, using config value
const logger = new Logger(config.loggerLevel);
export default logger;
export { Logger };
-43
View File
@@ -1,43 +0,0 @@
{
"manifest_version": 3,
"name": "Meet caption saver",
"version": "0.1.0",
"description": "Save .srt file of conversation",
"action": {
"default_popup": "popup.html"
},
"permissions": [
"storage"
],
"host_permissions": [
"http://meet.google.com/*",
"https://meet.google.com/*"
],
"content_scripts": [
{
"matches": [
"http://meet.google.com/*",
"https://meet.google.com/*"
],
"js": [
"content.js"
]
}
],
"background": {
"service_worker": "background.js",
"type": "module"
},
"web_accessible_resources": [
{
"resources": [
"logger.js",
"config.js"
],
"matches": [
"http://meet.google.com/*",
"https://meet.google.com/*"
]
}
]
}
-93
View File
@@ -1,93 +0,0 @@
body {
min-width: 100px;
min-height: 60px;
display: flex;
justify-content: center;
align-items: center;
}
.container {
display: flex;
flex-direction: column;
align-items: center;
margin-top: 3px;
}
.downloadBtn {
margin: 6px 1px;
padding: 3px 3px;
font-size: 12px;
cursor: pointer;
}
.error-message {
color: #ff4444;
font-size: 12px;
text-align: center;
margin-top: 8px;
padding: 0 10px;
max-width: 200px;
word-wrap: break-word;
display: none;
}
/* The switch - the box around the slider */
.switch {
position: relative;
width: 60px;
height: 34px;
display: flex;
justify-content: center;
align-items: center;
margin: 6px 1px;
}
/* Hide default HTML checkbox */
.switch input {
opacity: 0;
width: 0;
height: 0;
}
/* The slider */
.slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #ccc;
}
.slider::before {
position: absolute;
content: "";
height: 26px;
width: 26px;
left: 4px;
bottom: 4px;
background-color: white;
}
input:checked+.slider {
background-color: #2196F3;
}
input:checked+.slider:before {
transform: translateX(26px);
/* Move the slider to the right when checked */
}
/* Rounded sliders */
.slider.round {
border-radius: 34px;
}
.slider.round::before {
border-radius: 50%;
}
.secret {
margin: 5px;
}
-21
View File
@@ -1,21 +0,0 @@
<html lang="en">
<head>
<title>My popup</title>
<link rel="stylesheet" type="text/css" href="popup.css">
</head>
<body>
<div class="container">
<label class="switch">
<input id="slider" type="checkbox">
<span class="slider round"></span>
</label>
<button class="downloadBtn">Download .srt</button>
<div id="errorMessage" class="error-message"></div>
</div>
<script type="module" src="popup.js"></script>
</body>
</html>
-118
View File
@@ -1,118 +0,0 @@
"use strict";
import logger from './logger.js';
// #region Constants and Helper Functions
function setBadgeText(enabled) {
const text = enabled ? "ON" : "OFF"
void chrome.action.setBadgeText({text: text})
}
function isGoogleMeetPage(url) {
return url && (url.startsWith('http://meet.google.com/') || url.startsWith('https://meet.google.com/'));
}
function showErrorMessage(message) {
const errorElement = document.getElementById("errorMessage");
errorElement.textContent = message;
errorElement.style.display = message ? "block" : "none";
}
function handleContentScriptError(error) {
logger.debug("Error sending message:", chrome.runtime.lastError);
if (error.message.includes("Receiving end does not exist")) {
showErrorMessage("Please refresh the Google Meet page to use this extension");
} else {
showErrorMessage("An error occurred. Please try again.");
}
}
function resetSliderState() {
checkbox.checked = false;
setBadgeText(false);
chrome.storage.sync.set({ "slider": false });
}
// #endregion Constants and Helper Functions
// #region DOM Elements
const checkbox = document.getElementById("slider");
const downloadButton = document.querySelector(".downloadBtn");
// #endregion DOM Elements
// #region Storage Operations
function initializeSliderState() {
chrome.storage.sync.get("slider", (data) => {
const isEnabled = !!data.slider;
checkbox.checked = isEnabled;
setBadgeText(isEnabled);
});
}
function saveSliderState(isChecked) {
chrome.storage.sync.set({ "slider": isChecked }, (error) => {
if (error) {
showErrorMessage("Failed to save settings. Please try again.");
return;
}
setBadgeText(isChecked);
});
}
// #endregion Storage Operations
// #region Message Handling
function sendMessageToContentScript(tabId, action) {
chrome.tabs.sendMessage(tabId, { action }, (response) => {
if (chrome.runtime.lastError) {
handleContentScriptError(chrome.runtime.lastError);
return;
}
showErrorMessage(""); // Clear any previous error messages
});
}
function handleTabAction(action) {
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
if (tabs.length === 0) {
showErrorMessage("No active tab found");
resetSliderState();
return;
}
const currentTab = tabs[0];
if (!isGoogleMeetPage(currentTab.url)) {
showErrorMessage("Extension can only be used on Google Meet pages");
resetSliderState();
return;
}
sendMessageToContentScript(currentTab.id, action);
});
}
// #endregion Message Handling
// #region Event Listeners
function setupSliderListener() {
checkbox.addEventListener("change", (event) => {
const isChecked = event.target.checked;
saveSliderState(isChecked);
handleTabAction(isChecked ? "startCapture" : "stopCapture");
});
}
function setupDownloadButtonListener() {
downloadButton.addEventListener("click", () => {
handleTabAction("downloadSrt");
});
}
// #endregion Event Listeners
// #region Initialization
function initializePopup() {
logger.debug("Initializing popup");
initializeSliderState();
setupSliderListener();
setupDownloadButtonListener();
}
// Start the popup initialization
initializePopup();
// #endregion Initialization
+51
View File
@@ -0,0 +1,51 @@
# Action — Doc Writing
با قواعد اینجا باید اکشن‌ها رو بسازی که داک‌های من و خودت هست و بعد از اجرا اگر
جزییاتی داشت که من نیاز داشت بدونم توی ریزالت یک ریزالت با همین نام می‌سازی و
نتیجه‌ی کارت رو داک می‌کنی.
اکشن‌ها جنرال هستن و روی موقعیت‌های مشابه ممکنه همون داک رو مجدد بهت بدم.
اما ریزالت می‌تونه داخلش اطلاعات اون اجرات و درس‌آموخته‌هات باشه.
---
## ساختار فایل‌ها
```
docs/
├── action/ ← عمومی، قابل استفاده مجدد
└── result/ ← مخصوص هر اجرا
```
**فرمت نام فایل:**
```
{sequential_numbering_xxx}_action_{short_title_of_doc}.md
{sequential_numbering_xxx}_result_{short_title_of_doc}.md
```
- شماره‌ی `NNN` بین جفت action/result **مشترک** است.
- عنوان کوتاه (`short_title`) در هر دو **یکسان** است.
---
## action چیست؟
- **جنرال** — هر بار که موقعیت مشابه پیش آمد، همین فایل داده می‌شود.
- **بدون جزئیات اجرا** — هیچ نام فایل، تاریخ، یا تصمیم مخصوص یک run توش نباشد.
- می‌تواند شامل: هدف، چک‌لیست کارها، قواعد، یا context کلی باشد.
- اگر در حین اجرا متوجه شدی که action ناقص یا گمراه‌کننده بوده، **آن را بهبود بده**.
## result چیست؟
- **مخصوص همان اجرا** — تاریخ، فایل‌های ساخته‌شده، تصمیم‌های گرفته‌شده.
- **درس‌آموخته** — چیزی که دفعه‌ی بعد باید متفاوت انجام شود.
- **وضعیت** — چه چیزی انجام شد، چه چیزی باقی ماند.
- **نیاز به تصمیم** — اگر چیزی هست که کاربر باید تصمیم بگیرد، اینجا بنویس.
---
## قواعد کلی
- result را **بعد از اجرا** بساز، نه قبل از آن.
- اگر result از قبل وجود داشت (اجرای مجدد همان action)، یا آپدیتش کن یا یک بخش
جدید با تاریخ جدید اضافه کن — فایل را overwrite نکن.
- اطلاعاتی که «کاربر نیاز دارد بداند» را در result بنویس، نه فقط خلاصه‌ی فنی.
+77
View File
@@ -0,0 +1,77 @@
- [ ] از اسکریپت laxi هرچیزی که اضافه هست رو حذف کنیم.
- [ ] برای لوگو یک لوگو از خودمون میذاریم.
- [ ] مثلا لاگین یا اگر داده‌ای به جایی میفرسته نباید بفرسته اینا هم اضافی هستن
- [ ] هدف نهایی: بتونیم با استفاده از این پلاگین کپشن رو بگیریم
- [ ] راهکارها:
- [ ] پلاگین خودش یک mcp سرویس بده روی یک پورت
---
<!-- Claude Code comments -->
**درباره حذف کدهای laxis:**
فایل‌هایی که احتمالاً باید بررسی بشن: `analytics.js`، `login.js`، `runtime.js` (در `google-meet-transcripts-extension/`) — اینا بیشترین احتمال ارتباط با سرور laxis رو دارن. `config/` هم ممکنه endpoint داشته باشه. پیشنهاد: اول یه `grep` روی همه فایل‌ها برای `laxis` و `api.` و `fetch(` بزن تا دقیق بفهمیم کجاها data میفرسته.
**درباره MCP روی پورت:**
این رویکرد خوبیه ولی یه محدودیت داره — Chrome extension نمیتونه مستقیم TCP server باز کنه. راه‌حل‌های ممکن:
1. **Native Messaging:** extension با یه اپ native (Python/Node) صحبت میکنه که اون اپ MCP server هست — پیچیده‌تر ولی استانداردتر
2. **WebSocket از extension:** extension یه WebSocket server روی `localhost` باز میکنه از طریق یه background service worker + `chrome.sockets` API — محدودیت‌های manifest v3 داره
3. **Polling روی clipboard یا فایل:** ساده‌ترین راه — extension هر X ثانیه caption رو در یه فایل یا clipboard میریزه، اپ دیگه‌ای میخونه
کدوم راهکار رو در نظر داری؟
---
# پیاده‌سازی — معماری Bridge
> راهکار انتخابی: **افزونه به‌عنوان WebSocket _client_** به یک سرور پایتونِ بیرون مرورگر وصل می‌شود.
> (نه راهکار ۲ که افزونه خودش server باشد — در MV3 سرویس‌ورکر نمی‌تواند پورت TCP باز کند.)
## دیاگرام جریان داده
```mermaid
flowchart TD
A["Google Meet<br/>caption روی صفحه"] -->|"DOM observer"| B["content script<br/>captionProcessing.js"]
B -->|"setSpeaker()"| C["storage.js<br/>نقطه‌ی choke هر caption"]
C -->|"chrome.runtime.sendMessage<br/>{type: TRANSCRIPT_UPDATE}"| D["service worker<br/>bridge.js"]
D -->|"WebSocket<br/>ws://127.0.0.1:8765"| E["سرور پایتون<br/>bridge/server.py"]
E -->|"ذخیره"| F["transcripts/&lt;sessionId&gt;.txt"]
E -.->|"ACK / PONG"| D
E ==>|"گام بعدی"| G["MCP Server"]
G ==> H["Claude<br/>transcript زنده را می‌خواند"]
style E fill:#2F5496,color:#fff
style G fill:#888,color:#fff,stroke-dasharray: 5 5
style H fill:#888,color:#fff,stroke-dasharray: 5 5
```
## چرا client و نه server؟
- **CSP گوگل میت:** اگر WebSocket را از content script باز کنیم، `connect-src` صفحه بلاکش می‌کند.
سرویس‌ورکر تابع CSP صفحه نیست → اتصال از آن‌جا باز می‌شود.
- **MV3 لایف‌سایکل:** سرویس‌ورکر بعد ~۳۰ ثانیه می‌خوابد. دو مکانیزم نگهش می‌دارد:
(۱) هر پیام جدید از content script بیدارش می‌کند، (۲) heartbeat (PING) هر ۲۰ ثانیه
(از Chrome 116+ پیام WebSocket زیر ۳۰ ثانیه ورکر را زنده نگه می‌دارد).
## وضعیت فعلی — ✅ گام ۱ انجام شد (MVP اتصال)
| قطعه | فایل | کار |
|------|------|-----|
| سرور پایتون | `bridge/server.py` | WebSocket روی `127.0.0.1:8765`، ذخیره در `transcripts/` |
| WS client | `google-meet-transcripts-extension/bridge.js` | اتصال + reconnect + heartbeat |
| ایمپورت در ورکر | `login.js` | `importScripts("bridge.js")` |
| نقطه‌ی hook | `storage.js``setSpeaker` | ارسال `TRANSCRIPT_UPDATE` |
| دسترسی شبکه | `manifest.json` | `ws://127.0.0.1:8765/*` در host_permissions |
تست end-to-end سمت پایتون با `_smoke_test.py` پاس شد (PONG + ACK + ذخیره‌ی فایل).
## مراحل بعدی
- [ ] **پاک‌سازی کد laxis** (طبق بالای همین سند): حذف لاگین، analytics، و fetch به `domainUrl`.
- [ ] **تست میدانی با Chrome واقعی:** سرور را بالا بیاور، افزونه را Reload کن، در یک Meet
caption را روشن کن و خروجی را در ترمینال/فایل ببین.
- [ ] **افزودن MCP server** روی همین `server.py` (مثلاً با `mcp` SDK پایتون) تا Claude
بتواند transcript زنده را به‌عنوان resource/tool بخواند.
- [ ] **لوگوی اختصاصی** جایگزین لوگوی laxis.
## محیط توسعه
پایتون داخل **venv** اجرا می‌شود تا سیستم تمیز بماند (راهنما: `bridge/README.md`).
@@ -0,0 +1,52 @@
# Result — 001 Plan Doc Writing
> اکشن مرتبط: [`action/001_action_plan_doc_writing.md`](001_action_doc_writing.md)
> تاریخ اجرا: 2026-06-09
این ریزالتِ اجرای قاعده‌ی «نوشتن داک پلن» است. خودِ اکشن جنرال است؛ این‌جا فقط
جزئیات این اجرا و درس‌آموخته‌ها ثبت می‌شود تا دفعه‌ی بعد سریع‌تر و بی‌خطا انجام شود.
## قاعده‌ای که فهمیدم و اعمال کردم
سیستم دو لایه است:
| لایه | مسیر | ماهیت |
|------|------|-------|
| Action | `docs/action/NNN_action_*.md` | عمومی، قابل‌استفاده‌ی مجدد، ورودیِ کار |
| Result | `docs/result/NNN_result_*.md` | مخصوص همان اجرا، شامل درس‌آموخته‌ها، خروجیِ کار |
قواعد:
- نام result دقیقاً قرینه‌ی action است: `NNN_action_X``NNN_result_X`.
- شماره‌ی `NNN` بین جفتِ action/result مشترک است.
- چون action ممکن است دوباره برای موقعیت مشابه داده شود، **هیچ جزئیاتِ مخصوصِ یک اجرا نباید داخل action برود** — همه‌ی آن‌ها در result.
- result باید «چیزهایی که کاربر لازم است بداند» + «درس‌آموخته» را داشته باشد.
## چه چیزی در این اجرا تولید شد
سند پلن (`docs/Plan.md`) با یک سکشن **«پیاده‌سازی — معماری Bridge»** گسترش یافت:
- دیاگرام Mermaid از جریان داده (Meet → content script → service worker → پایتون → فایل → MCP/Claude)
- توضیح «چرا client نه server» (CSP گوگل میت + لایف‌سایکل MV3)
- جدول وضعیت گام ۱ + چک‌لیست مراحل بعدی
همین محتوا در `docs/action/002_action_plan.md` به‌عنوان نسخه‌ی action نگه داشته شده.
## درس‌آموخته‌ها (برای دفعه‌ی بعد)
1. **Mermaid در Obsidian:** متنِ هر node را داخل `"..."` بگذار و برای شکستن خط از
`<br/>` استفاده کن. کاراکترهای `<` و `>` داخل لیبل را به‌صورت `&lt;` و `&gt;`
بنویس وگرنه پارسر Mermaid خطا می‌دهد (مثلاً `<sessionId>``&lt;sessionId&gt;`).
2. **جهت‌دهی فارسی + کد:** عنوان‌ها و توضیح فارسی، ولی نام فایل/کد/پروتکل را داخل
backtick انگلیسی نگه دار تا RTL آن‌ها را به‌هم نریزد.
3. **لینک‌های نسبی:** result در `docs/result/` است و action در `docs/action/`؛ برای
ارجاع از داخل result به action از `../action/...` استفاده کن (که در Obsidian و
هم در GitHub درست باز می‌شود).
4. **`002_result_plan.md` خالی مانده:** قرینه‌اش (`002_action_plan`) پر است ولی
ریزالتش هنوز نوشته نشده — اگر آن اجرا کامل شد باید پر شود.
5. **همگام‌سازی action 002 با Plan.md:** الان `docs/Plan.md` و
`docs/action/002_action_plan.md` محتوای یکسانی دارند؛ اگر یکی تغییر کرد باید
تصمیم بگیریم کدام «منبع حقیقت» است تا واگرا نشوند.
## باز است / نیاز به تصمیم
- منبع حقیقتِ پلن کدام است: `docs/Plan.md` یا `docs/action/002_action_plan.md`؟
- آیا result ها هم باید در Obsidian قابل‌ناوبری باشند (مثلاً با لینک دوطرفه به action)؟
+116
View File
@@ -0,0 +1,116 @@
# Result — 002 Planning
> اکشن مرتبط: [`action/002_action_planning.md`](../action/002_action_planning.md)
> تاریخ اجرا: 2026-06-09
---
## هدف این جلسه
خواندن پروژه، فهمیدن ساختار، و پیاده‌سازی گام اول MVP:
اتصال افزونه‌ی Chrome به یک سرور پایتون بیرون مرورگر از طریق WebSocket.
---
## تصمیم‌های کلیدی که گرفته شد
### معماری — چرا extension به‌عنوان WS client؟
افزونه نمی‌تواند خودش سرور باشد (MV3 سرویس‌ورکر TCP port نمی‌تواند باز کند).
پس **افزونه client است و پایتون server** — افزونه به `ws://127.0.0.1:8765` وصل می‌شود.
همچنین WS باید از service worker باز شود، نه content script؛ چون content script
تابع CSP صفحه‌ی گوگل میت است و `connect-src` اتصال را بلاک می‌کند.
### نقطه‌ی hook در کد افزونه
`setSpeaker()` در `storage.js` — هر پاراگراف caption نهایی از این تابع رد می‌شود.
یک `chrome.runtime.sendMessage` کوچک به آن اضافه شد که background را خبر می‌کند.
### venv برای پایتون
تصمیم گرفته شد پایتون داخل `.venv` اجرا شود تا سیستم کثیف نشود.
---
## چه چیزی ساخته شد
| فایل | نوع | کار |
|------|-----|-----|
| `bridge/server.py` | جدید | WebSocket server پایتون روی `127.0.0.1:8765` |
| `bridge/requirements.txt` | جدید | فقط `websockets>=12.0` |
| `bridge/.gitignore` | جدید | `.venv/` و `transcripts/` از git خارج |
| `bridge/README.md` | جدید | راهنمای اجرا با venv |
| `bridge/_smoke_test.py` | جدید | کلاینت تستی بدون Chrome |
| `bridge/.venv/` | جدید | محیط ایزوله پایتون |
| `google-meet-transcripts-extension/bridge.js` | جدید | WS client داخل service worker |
| `login.js` | ویرایش | اضافه شدن `importScripts("bridge.js")` |
| `storage.js` | ویرایش | `setSpeaker` حالا `TRANSCRIPT_UPDATE` می‌فرستد |
| `manifest.json` | ویرایش | `ws://127.0.0.1:8765/*` به `host_permissions` اضافه شد |
---
## تست انجام شده
سرور پایتون با `_smoke_test.py` end-to-end تست شد:
- PING → PONG ✅
- سه `TRANSCRIPT_UPDATE` → سه ACK ✅
- فایل `transcripts/meeting-abc123.txt` با متن فارسی درست ذخیره شد ✅
تست با Chrome واقعی هنوز انجام نشده (نیاز به Reload افزونه و یک جلسه‌ی Meet).
---
## وضعیت چک‌لیست اصلی (از action)
- [x] حذف کدهای laxis — **انجام شد** (اجرای ۲۰۲۶-۰۶-۱۰، پایین را ببین)
- [x] لوگوی اختصاصی — **انجام شد** (لوگوی جدید جایگزین شد)
- [x] گرفتن caption از افزونه — گام ۱ (اتصال) انجام شد
- [x] سرویس MCP روی پورت — **انجام شد** (روی همان `server.py`)
- [ ] تست میدانی با Chrome واقعی — باقی‌مانده (نیاز به مرورگر و یک جلسه‌ی Meet)
---
## اجرای دوم — پاک‌سازی laxis + MCP + لوگو (۲۰۲۶-۰۶-۱۰)
### پاک‌سازی laxis (هیچ داده‌ای دیگر بیرون نمی‌رود)
- `analytics.js`: کاملاً خالی شد (از قبل کامنت‌شده و مرده بود؛ در manifest هم لود نمی‌شد).
- `login.js` (service worker): بازنویسی شد — حذف `tabs.onRemoved` (که transcript را آپلود می‌کرد)،
حذف `onMessageExternal` (دریافت توکن) و `getTopics`. فقط import ها + نگه‌داشتن وضعیت لوکال ماند.
- `runtime.js` (content script): حذف فراخوانی `addRemindLogin()` و کل listener دریافت توکن/fetch.
- `meetingInfo.js`: `checkToken` و `renewToken` با `return;` در ابتدا خنثی شدند؛ آپلود پایان جلسه
(`Export2App` + باز کردن `domainUrl/transcript`) حذف شد.
- `transcript.js`: `Export2App` بلافاصله reject می‌کند (آپلود ابری قطع)، `getTopics` خنثی شد،
`addRemindLogin` خالی شد، منوی دانلود از حالت پیش‌فرضِ «app/cloud» به دانلود محلی `txt` تغییر کرد.
- `panel/main.js`: لینک لوگو → `domainUrl/login` در هر سه جا حذف شد؛ متن‌های برند «Laxis…» حذف شدند.
- `manifest.json`: `update_url` فروشگاه حذف شد (که افزونه با ID لاکسیس آپدیت نشود)؛ name/description
به نسخه‌ی محلی و بدون cloud تغییر کرد.
### MCP روی server.py
سرور حالا در یک پراسس هم WebSocket (برای افزونه) و هم MCP stdio (برای Claude) را اجرا می‌کند و
حافظه‌ی `sessions` را share می‌کنند. ابزارها: `list_sessions`، `get_transcript`،
`get_latest_transcript` و resource با الگوی `transcript://{session_id}`. کانفیگ Claude Desktop در
`bridge/README.md`.
### درس‌آموخته‌ها
- **stdout برای MCP رزرو است:** همه‌ی `print` ها به stderr منتقل شدند، وگرنه استریم JSON-RPC خراب می‌شود.
برای تست دستیِ WebSocket باید از `python server.py --ws-only` استفاده کرد.
- **کد افزونه minified است:** برای فایل‌های فشرده (`meetingInfo.js`) به‌جای حذفِ بدنه‌ها، با `return;`
در ابتدای تابع خنثی‌سازی شد (کم‌ریسک‌تر). کدِ مرده باقی می‌ماند ولی هرگز اجرا نمی‌شود.
- **یک رشته‌ی غیرفعال باقی‌ست:** متنِ «Autosave to Laxis cloud…» در `displayGoogleMeetQuota`
(`transcript.js`) داخل کدِ مرده مانده — هرگز نمایش داده نمی‌شود چون فراخوان‌هایش خنثی‌اند.
### تأیید (validation)
- syntax همه‌ی فایل‌های JS با `node --check` پاس شد.
- منطق `server.py` با AST + یک smoke test واقعیِ WebSocket (PONG/ACK + ذخیره‌ی صحیح فارسی) تأیید شد.
- ابزار/resource های MCP با `mcp` SDK ساخته و اجرا شدند (`get_latest_transcript` متن را درست برگرداند).
### باقی‌مانده برای تو
تست میدانی: venv را بساز/فعال کن، `python server.py --ws-only` را اجرا کن، افزونه را در
`chrome://extensions` **Reload** کن، در یک Meet واقعی caption را روشن کن و خروجی را در
`bridge/transcripts/` ببین. بعد کانفیگ MCP را به Claude Desktop اضافه کن.
@@ -1 +1,4 @@
//!function(e,t,c,r,n,s){e.GoogleAnalyticsObject=r,e[r]=e[r]||function(){(e[r].q=e[r].q||[]).push(arguments)},e[r].l=1*new Date,n=t.createElement(c),s=t.getElementsByTagName(c)[0],n.async=1,n.src=chrome.runtime.getURL("analytics/analyticsSource.js"),n.id="laxis-track",s.parentNode.insertBefore(n,s)}(window,document,"script","ga");let firstScript=document.getElementsByTagName("script")[1],s=document.createElement("script");s.src=chrome.runtime.getURL("analytics/load.js"),firstScript.parentNode.insertBefore(s,firstScript); // analytics.js — غیرفعال شد.
// در نسخه‌ی اصلی laxis این فایل Google Analytics (laxis-track) را تزریق می‌کرد
// و به analytics/analyticsSource.js و analytics/load.js وابسته بود (که دیگر وجود ندارند).
// برای حذف ردیابی و ارسال داده، محتوای آن کاملاً برداشته شد. این فایل در manifest هم لود نمی‌شود.
@@ -0,0 +1,68 @@
// bridge.js — پل ارتباطی افزونه با سرور لوکال پایتون (MVP)
// این فایل داخل service worker اجرا می‌شود (importScripts از login.js).
// content script هر caption را با پیام {type:"TRANSCRIPT_UPDATE"} می‌فرستد،
// و این‌جا روی WebSocket به سرور پایتون (ws://127.0.0.1:8765) forward می‌شود.
//
// چرا از service worker و نه content script؟ چون content script تابعِ CSP سختگیر
// google meet است و connect-src اتصال ws را بلاک می‌کند؛ service worker این محدودیت را ندارد.
const BRIDGE_URL = "ws://127.0.0.1:8765";
let ws = null;
const pending = []; // پیام‌هایی که موقع وصل‌نبودن سوکت صف می‌شوند (MVP: کوتاه)
function connect() {
if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) {
return;
}
ws = new WebSocket(BRIDGE_URL);
ws.onopen = () => {
console.log("[bridge] connected to", BRIDGE_URL);
while (pending.length && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(pending.shift()));
}
};
ws.onmessage = (event) => {
// پاسخ سرور پایتون (ACK / PONG) — فعلاً فقط لاگ
console.log("[bridge] <-", event.data);
};
ws.onclose = () => {
console.log("[bridge] disconnected, retry in 3s");
ws = null;
setTimeout(connect, 3000);
};
ws.onerror = () => {
try { ws && ws.close(); } catch (e) {}
};
}
function sendToBridge(payload) {
connect();
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(payload));
} else {
pending.push(payload);
if (pending.length > 500) pending.splice(0, pending.length - 500);
}
}
// heartbeat: از Chrome 116+ تبادل پیامِ کمتر از ۳۰ ثانیه، service worker را زنده نگه می‌دارد
setInterval(() => {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "PING", ts: Date.now() }));
}
}, 20000);
// دریافت caption از content script و forward به سرور پایتون
chrome.runtime.onMessage.addListener((message) => {
if (message && message.type === "TRANSCRIPT_UPDATE") {
sendToBridge(message);
}
});
connect();
@@ -1 +1 @@
const domainUrl="https://app.laxis.tech",extensionId=chrome.runtime.id,loginUrl=domainUrl+"/login?source=chrome-extension&extensionId="+extensionId,upgradeUrl=domainUrl+"/settings/plan",signupUrl=domainUrl+"/signup?source=chrome-extension&extensionId="+extensionId,googleMeetUrl="https://meet.google.com/*"; const domainUrl="https://example.com",extensionId=chrome.runtime.id,loginUrl=domainUrl+"/login?source=chrome-extension&extensionId="+extensionId,upgradeUrl=domainUrl+"/settings/plan",signupUrl=domainUrl+"/signup?source=chrome-extension&extensionId="+extensionId,googleMeetUrl="https://meet.google.com/*";
@@ -12,8 +12,8 @@ const addTutorialPanel = () => {
t.style.display = "flex", t.style.alignItems = "center", t.style.paddingLeft = "8px"; t.style.display = "flex", t.style.alignItems = "center", t.style.paddingLeft = "8px";
const i = document.createElement("img"); const i = document.createElement("img");
i.src = chrome.runtime.getURL("image/logo.png"), i.addEventListener("click", (function() { i.src = chrome.runtime.getURL("image/logo.png"), i.addEventListener("click", (function() {
window.open(`${domainUrl}/login`) void 0 /* لینک لاگین laxis حذف شد */
})), i.alt = "none", i.style.height = "30px", i.style.width = "90px", i.style.marginLeft = "-16px", i.style.cursor = "pointer", i.style.zIndex = "999", t.appendChild(i), e.appendChild(t); })), i.alt = "none", i.style.height = "30px", i.style.width = "90px", i.style.marginLeft = "0px", i.style.cursor = "pointer", i.style.zIndex = "999", t.appendChild(i), e.appendChild(t);
const o = document.createElement("div"); const o = document.createElement("div");
o.style.display = "flex", o.style.alignItems = "center"; o.style.display = "flex", o.style.alignItems = "center";
const d = document.createElement("div"); const d = document.createElement("div");
@@ -42,7 +42,7 @@ const addTutorialPanel = () => {
const g = createCaptionOnIcon(); const g = createCaptionOnIcon();
y.appendChild(g), u.appendChild(y), s.appendChild(u); y.appendChild(g), u.appendChild(y), s.appendChild(u);
let h = document.createElement("div"); let h = document.createElement("div");
h.style.textAlign = "center", h.innerHTML = "Laxis supports 69 languages in Google Meet", s.appendChild(h); h.style.textAlign = "center", h.innerHTML = "Supports 69 languages in Google Meet", s.appendChild(h);
let x = document.createElement("div"); let x = document.createElement("div");
x.style.paddingTop = "32px", x.innerHTML = "Download", s.appendChild(x); x.style.paddingTop = "32px", x.innerHTML = "Download", s.appendChild(x);
let C = document.createElement("div"); let C = document.createElement("div");
@@ -52,7 +52,7 @@ const addTutorialPanel = () => {
let E = createDownloadIcon(); let E = createDownloadIcon();
v.appendChild(E), C.appendChild(v), s.appendChild(C); v.appendChild(E), C.appendChild(v), s.appendChild(C);
let b = document.createElement("div"); let b = document.createElement("div");
b.style.textAlign = "center", b.innerHTML = "Laxis provides multiple formats for downloading the transcripts.", s.appendChild(b), l.appendChild(s), n.appendChild(l), dragElement(n) b.style.textAlign = "center", b.innerHTML = "Download transcripts in multiple formats.", s.appendChild(b), l.appendChild(s), n.appendChild(l), dragElement(n)
} }
}; };
@@ -68,9 +68,7 @@ function addMiniPanel() {
const n = document.createElement("div"); const n = document.createElement("div");
n.setAttribute("name", "laxis-rootHeader"), n.style.cursor = "move", n.classList.add("col-12"), n.style.height = "50px", n.style.width = "50px", n.style.marginBottom = "-10px", n.style.cursor = "move", n.style.zIndex = "999"; n.setAttribute("name", "laxis-rootHeader"), n.style.cursor = "move", n.classList.add("col-12"), n.style.height = "50px", n.style.width = "50px", n.style.marginBottom = "-10px", n.style.cursor = "move", n.style.zIndex = "999";
const l = document.createElement("img"); const l = document.createElement("img");
l.src = chrome.runtime.getURL("image/logo.png"), l.addEventListener("click", (function() { l.src = chrome.runtime.getURL("image/logo128x128.png"), l.style.cursor = "pointer", l.style.marginTop = "5px", l.style.height = "40px", l.style.width = "40px", l.style.marginLeft = "5px", l.alt = "none", n.appendChild(l), t.appendChild(n);
window.open(`${domainUrl}/login`)
})), l.style.cursor = "pointer", l.style.marginTop = "10px", l.style.height = "25px", l.style.width = "75px", l.style.marginLeft = "-16px", l.alt = "none", n.appendChild(l), t.appendChild(n);
const i = document.createElement("div"); const i = document.createElement("div");
i.title = "Expand", i.id = "laxis-expandPanel", i.classList.add("miniButtonContainer"), i.addEventListener("click", addTutorialPanel); i.title = "Expand", i.id = "laxis-expandPanel", i.classList.add("miniButtonContainer"), i.addEventListener("click", addTutorialPanel);
const o = createExpandIcon(); const o = createExpandIcon();
@@ -112,8 +110,8 @@ const addCaptionPanel = () => {
p.style.display = "flex", p.style.alignItems = "center", p.style.paddingLeft = "8px"; p.style.display = "flex", p.style.alignItems = "center", p.style.paddingLeft = "8px";
const m = document.createElement("img"); const m = document.createElement("img");
m.src = chrome.runtime.getURL("image/logo.png"), m.alt = "none", m.addEventListener("click", (function() { m.src = chrome.runtime.getURL("image/logo.png"), m.alt = "none", m.addEventListener("click", (function() {
window.open(`${domainUrl}/login`) void 0 /* لینک لاگین laxis حذف شد */
})), m.style.height = "30px", m.style.marginLeft = "-16px", m.style.cursor = "pointer", m.style.zIndex = "999", p.appendChild(m), r.appendChild(p); })), m.style.height = "30px", m.style.marginLeft = "0px", m.style.cursor = "pointer", m.style.zIndex = "999", p.appendChild(m), r.appendChild(p);
const u = document.createElement("div"); const u = document.createElement("div");
u.style.display = "flex", u.style.alignItems = "center"; u.style.display = "flex", u.style.alignItems = "center";
const y = document.createElement("div"); const y = document.createElement("div");
@@ -144,7 +142,6 @@ const addCaptionPanel = () => {
const I = document.createElement("div"); const I = document.createElement("div");
I.style.width = "300px", I.style.backgroundColor = "#292c35", I.style.paddingTop = "8px"; I.style.width = "300px", I.style.backgroundColor = "#292c35", I.style.paddingTop = "8px";
const w = document.createElement("div"); const w = document.createElement("div");
w.id = "login-prompt", w.style.display = "none", w.style.margin = "0px 8px", w.style.padding = "4px", w.style.textAlign = "center", w.addEventListener("click", signup), w.style.cursor = "pointer", w.innerHTML = "Please <span style='color:#2196f3'>log in</span> to enable autosave to Laxis Cloud.", w.style.borderRadius = "12px", w.style.border = "1px solid rgba(255, 255, 255, 0.5)", w.style.backgroundColor = "#454953", w.style.color = "#ffffff", w.style.fontSize = "10px";
const T = document.createElement("div"); const T = document.createElement("div");
T.id = "google-meet-quota", T.style.display = "none", T.style.margin = "0px 8px", T.style.padding = "4px", T.style.textAlign = "center", T.addEventListener("click", upgrade), T.style.cursor = "pointer", T.innerHTML = "Used Google Meet Quota: ", T.style.borderRadius = "12px", T.style.border = "1px solid rgba(255, 255, 255, 0.5)", T.style.backgroundColor = "#454953", T.style.color = "#ffffff", T.style.fontSize = "10px"; T.id = "google-meet-quota", T.style.display = "none", T.style.margin = "0px 8px", T.style.padding = "4px", T.style.textAlign = "center", T.addEventListener("click", upgrade), T.style.cursor = "pointer", T.innerHTML = "Used Google Meet Quota: ", T.style.borderRadius = "12px", T.style.border = "1px solid rgba(255, 255, 255, 0.5)", T.style.backgroundColor = "#454953", T.style.color = "#ffffff", T.style.fontSize = "10px";
const M = document.createElement("div"); const M = document.createElement("div");
@@ -192,13 +189,11 @@ const addCaptionPanel = () => {
n.className = "modal-header", n.innerHTML = "Download", t.appendChild(n); n.className = "modal-header", n.innerHTML = "Download", t.appendChild(n);
let l = document.createElement("div"), let l = document.createElement("div"),
i = document.createElement("div"); i = document.createElement("div");
i.className = "modal-body", i.innerHTML = "File Type<select name='extension' id='extension' value='txt'><option value='txt'>txt</option><option value='app'>Laxis Cloud</option><option value='doc'>doc</option><option value='pdf'>pdf</option></select>", l.appendChild(i);
let o = document.createElement("div"); let o = document.createElement("div");
o.className = "modal-body", o.innerHTML = "<div>Highlights</div><div><input type='checkbox' name='highlightCheck' id='highlightCheck' checked='true'/></div>", l.appendChild(o); o.className = "modal-body", o.innerHTML = "<div>Highlights</div><div><input type='checkbox' name='highlightCheck' id='highlightCheck' checked='true'/></div>", l.appendChild(o);
let d = document.createElement("div"); let d = document.createElement("div");
d.className = "modal-body", d.innerHTML = "<div>Timestamps</div><div><input type='checkbox' name='timestampCheck' id='timestampCheck'/></div>", l.appendChild(d); d.className = "modal-body", d.innerHTML = "<div>Timestamps</div><div><input type='checkbox' name='timestampCheck' id='timestampCheck'/></div>", l.appendChild(d);
let a = document.createElement("div"); let a = document.createElement("div");
a.className = "modal-body", a.title = "Autosave to Laxis every 5 minutes", a.style.cursor = "help", a.innerHTML = "<div>Autosave to Laxis Cloud</div><div><input type='checkbox' name='autoSaveCheck' id='autoSaveCheck'/></div>", l.appendChild(a), t.appendChild(l);
let s = document.createElement("div"); let s = document.createElement("div");
s.className = "modal-footer"; s.className = "modal-footer";
let c = document.createElement("button"); let c = document.createElement("button");
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
const makeFullKey=e=>`__laxis_${e}`,makeTranscriptKey=(...e)=>{const[t,r,o]=e,n=[`hangout_${t}`];return e.length>=2&&(n.push(`session_${r}`),e.length>=3&&n.push(`speaker_${o}`)),n.join("_")},get=e=>{const t=window.localStorage.getItem(makeFullKey(e));return"string"==typeof t||t instanceof String?(debug(e,t),JSON.parse(t)):t},set=(e,t,r=!1)=>{const o=makeFullKey(e),n=JSON.stringify(t),a=makeFullKey("rotationKeys");let s=JSON.parse(window.localStorage.getItem(a))||[],i=new Set(s);for(;;)try{window.localStorage.setItem(o,n),r&&(i.add(o),window.localStorage.setItem(a,JSON.stringify(Array.from(i))));break}catch(e){if(e instanceof DOMException&&("QuotaExceededError"===e.name||e.code===DOMException.QUOTA_EXCEEDED_ERR)){if(console.log("Local storage quota exceeded! Deleting old keys to make room for new ones. This may take a while..."),debug("Local storage quota exceeded! Deleting old keys to make room for new ones. This may take a while..."),i.size>0){for(let e=0;e<5&&i.size>0;e++){const e=i.values().next().value;i.delete(e),window.localStorage.removeItem(e)}window.localStorage.setItem(a,JSON.stringify(Array.from(i)));continue}console.error("No keys available to delete.");break}console.error("Unexpected error:",e);break}},remove=e=>{debug(`remove ${makeFullKey(e)}`),window.localStorage.removeItem(makeFullKey(e))},getOrSet=(e,t)=>{const r=get(e);return null==r?(set(e,t),t):r},increment=e=>{const t=get(e);if(null==t)return set(e,0),0;{let r=t+1;return set(e,r),r}},setSpeaker=e=>{set(makeTranscriptKey(e.transcriptId,e.sessionIndex,e.speakerIndex),{image:e.image,person:e.person,text:e.text,startedAt:e.startedAt,endedAt:e.endedAt,highlight:e.highlight},!0)},getTranscript=e=>{const t=get(makeTranscriptKey(e))||0;let r=[];const o=get(makeTranscriptKey(e,t))||0;for(let n=0;n<=o;n+=1){const o=get(makeTranscriptKey(e,t,n));if(o&&o.text&&o.text.match(/\S/g)){startTimeStored||(startTimeStored=new Date(o.startedAt),startTime=new Date(o.startedAt));const a={transcriptId:e,sessionIndex:t,speakerIndex:n,person:o.person in SPEAKER_NAME_MAP?SPEAKER_NAME_MAP[o.person]:o.person,startedAt:new Date(o.startedAt),endedAt:new Date(o.endedAt),image:o.image,text:o.text,highlight:o.highlight};r.push(a)}}return r},deleteTranscript=e=>{const t=get(makeTranscriptKey(e));for(let r=0;r<=t;r+=1){const t=get(makeTranscriptKey(e,r));for(let o=0;o<=t;o+=1)remove(makeTranscriptKey(e,r,o));remove(makeTranscriptKey(e,r))}remove(makeTranscriptKey(e)),get(makeTranscriptKey(`${e}_name`))&&remove(makeTranscriptKey(`${e}_name`));let r=get(KEY_TRANSCRIPT_IDS)||[],o=get(APPLICATION_SPEECH_IDS)||[];const n=r.indexOf(e),a=o.findIndex((t=>t.ext===e));r.splice(n,1),o.splice(a,1),debug("would set transcript to",r),debug("would set transcript pairs to",o),set(KEY_TRANSCRIPT_IDS,r),set(APPLICATION_SPEECH_IDS,o);const s=document.querySelector(`#${e}`);if(s){const e=s.parentNode;e.removeChild(s),0===e.children.length&&(e.parentNode.removeChild(e.previousSibling),e.parentNode.removeChild(e))}else debug(`transcriptNode doesn't exist for ${e}`)},deletePreviousTranscripts=()=>{let e=get(KEY_TRANSCRIPT_IDS)||[];if(e.length>1)for(let t of e)t!==currentTranscriptId&&deleteTranscript(t)}; const makeFullKey=e=>`__laxis_${e}`,makeTranscriptKey=(...e)=>{const[t,r,o]=e,n=[`hangout_${t}`];return e.length>=2&&(n.push(`session_${r}`),e.length>=3&&n.push(`speaker_${o}`)),n.join("_")},get=e=>{const t=window.localStorage.getItem(makeFullKey(e));return"string"==typeof t||t instanceof String?(debug(e,t),JSON.parse(t)):t},set=(e,t,r=!1)=>{const o=makeFullKey(e),n=JSON.stringify(t),a=makeFullKey("rotationKeys");let s=JSON.parse(window.localStorage.getItem(a))||[],i=new Set(s);for(;;)try{window.localStorage.setItem(o,n),r&&(i.add(o),window.localStorage.setItem(a,JSON.stringify(Array.from(i))));break}catch(e){if(e instanceof DOMException&&("QuotaExceededError"===e.name||e.code===DOMException.QUOTA_EXCEEDED_ERR)){if(console.log("Local storage quota exceeded! Deleting old keys to make room for new ones. This may take a while..."),debug("Local storage quota exceeded! Deleting old keys to make room for new ones. This may take a while..."),i.size>0){for(let e=0;e<5&&i.size>0;e++){const e=i.values().next().value;i.delete(e),window.localStorage.removeItem(e)}window.localStorage.setItem(a,JSON.stringify(Array.from(i)));continue}console.error("No keys available to delete.");break}console.error("Unexpected error:",e);break}},remove=e=>{debug(`remove ${makeFullKey(e)}`),window.localStorage.removeItem(makeFullKey(e))},getOrSet=(e,t)=>{const r=get(e);return null==r?(set(e,t),t):r},increment=e=>{const t=get(e);if(null==t)return set(e,0),0;{let r=t+1;return set(e,r),r}},setSpeaker=e=>{set(makeTranscriptKey(e.transcriptId,e.sessionIndex,e.speakerIndex),{image:e.image,person:e.person,text:e.text,startedAt:e.startedAt,endedAt:e.endedAt,highlight:e.highlight},!0);try{chrome.runtime.sendMessage({type:"TRANSCRIPT_UPDATE",sessionId:String(e.transcriptId),speaker:e.person,text:e.text,startedAt:e.startedAt,endedAt:e.endedAt})}catch(t){}},getTranscript=e=>{const t=get(makeTranscriptKey(e))||0;let r=[];const o=get(makeTranscriptKey(e,t))||0;for(let n=0;n<=o;n+=1){const o=get(makeTranscriptKey(e,t,n));if(o&&o.text&&o.text.match(/\S/g)){startTimeStored||(startTimeStored=new Date(o.startedAt),startTime=new Date(o.startedAt));const a={transcriptId:e,sessionIndex:t,speakerIndex:n,person:o.person in SPEAKER_NAME_MAP?SPEAKER_NAME_MAP[o.person]:o.person,startedAt:new Date(o.startedAt),endedAt:new Date(o.endedAt),image:o.image,text:o.text,highlight:o.highlight};r.push(a)}}return r},deleteTranscript=e=>{const t=get(makeTranscriptKey(e));for(let r=0;r<=t;r+=1){const t=get(makeTranscriptKey(e,r));for(let o=0;o<=t;o+=1)remove(makeTranscriptKey(e,r,o));remove(makeTranscriptKey(e,r))}remove(makeTranscriptKey(e)),get(makeTranscriptKey(`${e}_name`))&&remove(makeTranscriptKey(`${e}_name`));let r=get(KEY_TRANSCRIPT_IDS)||[],o=get(APPLICATION_SPEECH_IDS)||[];const n=r.indexOf(e),a=o.findIndex((t=>t.ext===e));r.splice(n,1),o.splice(a,1),debug("would set transcript to",r),debug("would set transcript pairs to",o),set(KEY_TRANSCRIPT_IDS,r),set(APPLICATION_SPEECH_IDS,o);const s=document.querySelector(`#${e}`);if(s){const e=s.parentNode;e.removeChild(s),0===e.children.length&&(e.parentNode.removeChild(e.previousSibling),e.parentNode.removeChild(e))}else debug(`transcriptNode doesn't exist for ${e}`)},deletePreviousTranscripts=()=>{let e=get(KEY_TRANSCRIPT_IDS)||[];if(e.length>1)for(let t of e)t!==currentTranscriptId&&deleteTranscript(t)};
@@ -111,21 +111,17 @@ reDisplayPrompt = () => {
e && (e.style.display = "block"), chrome.storage.local.remove("token") e && (e.style.display = "block"), chrome.storage.local.remove("token")
}, },
addRemindLogin = () => { addRemindLogin = () => {
console.log("add login"); // غیرفعال شد: دکمه‌ی «Login to autosave» مربوط به laxis cloud بود.
const e = document.getElementById("laxis-miniPanel"),
t = document.getElementById("laxis-expandPanel"),
o = document.createElement("div");
o.title = "Login to autosave notes", o.id = "laxis-remindLogin", o.style.width = "40px", o.style.height = "40px", o.classList.add("miniButtonContainer"), o.style.border = "0", o.addEventListener("click", signup), o.style.padding = "0";
const n = createRemindLoginIcon();
n.id = "remindLoginIcon", o.appendChild(n), n.id = "remindLoginIcon", o.style.display = "none", e.insertBefore(o, t)
}, },
reDisplayRemindLogin = () => { reDisplayRemindLogin = () => {
console.log("redisplay"); console.log("redisplay");
const e = document.getElementById("laxis-remindLogin"); const e = document.getElementById("laxis-remindLogin");
e && (e.style.display = "block"), chrome.storage.local.remove("token") e && (e.style.display = "block"), chrome.storage.local.remove("token")
}, },
getTopics = (e, t) => window.fetch(`${domainUrl}/api/v2/templates/${e}/topics`, t), getTopics = (e, t) => Promise.reject(new Error("Laxis cloud disabled")),
Export2App = async (e, t = !1) => new Promise(((o, n) => { Export2App = async (e, t = !1) => new Promise(((o, n) => {
// غیرفعال شد: آپلود transcript به laxis cloud حذف شده است. هیچ داده‌ای بیرون نمی‌رود.
return n(new Error("Laxis cloud disabled"));
chrome.storage.local.get(["token"], (function(i) { chrome.storage.local.get(["token"], (function(i) {
if (i.token) { if (i.token) {
let s = document.getElementById("laxis-openDownloadMenu"), let s = document.getElementById("laxis-openDownloadMenu"),
@@ -260,10 +256,10 @@ Export2App = async (e, t = !1) => new Promise(((o, n) => {
a = () => Export2Txt(s, e); a = () => Export2Txt(s, e);
break; break;
default: default:
a = () => Export2App(o) a = () => Export2Txt(s, e)
} }
a().then((e => { a().then((() => {
"app" === i && window.open(`${domainUrl}/transcript/${e}`, "_blank") // قبلاً برای گزینه‌ی «app» صفحه‌ی laxis cloud باز می‌شد؛ حذف شد.
})).catch((e => { })).catch((e => {
window.alert(e); window.alert(e);
const t = get(ERROR_SAVING) || []; const t = get(ERROR_SAVING) || [];
Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.7 KiB

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 667 B

After

Width:  |  Height:  |  Size: 778 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

+24 -116
View File
@@ -1,127 +1,35 @@
// login.js — service worker افزونه.
//
// نسخه‌ی پاک‌سازی‌شده: تمام ارتباط با laxis cloud حذف شده است.
// دیگر هیچ توکنی دریافت نمی‌شود و هیچ transcript ای به سروری بیرون فرستاده نمی‌شود.
// این ورکر فقط:
// ۱) اسکریپت‌های لازم (share, panel) و bridge محلی را import می‌کند،
// ۲) چند مقدار وضعیتِ لوکال (meetingId / meetingName / username) را نگه می‌دارد،
// ۳) bridge.js را بالا می‌آورد تا caption ها به سرور لوکال (ws://127.0.0.1:8765) برسند.
try { try {
importScripts("config/share.js"), importScripts("config/panel.js") importScripts("config/share.js"), importScripts("config/panel.js"), importScripts("bridge.js")
} catch (e) { } catch (e) {
console.error(e) console.error(e)
} }
let meetingId = null, let meetingId = null,
transcriptId = null, transcriptId = null,
meetingName = null, meetingName = null,
username = ""; username = "";
chrome.storage.session.setAccessLevel({ chrome.storage.session.setAccessLevel({
accessLevel: "TRUSTED_AND_UNTRUSTED_CONTEXTS" accessLevel: "TRUSTED_AND_UNTRUSTED_CONTEXTS"
}); });
const getTopics = (e, t) => fetch(`${domainUrl}/api/v2/templates/${e}/topics`, t);
chrome.tabs.onRemoved.addListener(((e, t) => { // فقط نگه‌داشتن وضعیتِ لوکال؛ هیچ درخواست شبکه‌ای انجام نمی‌شود.
chrome.storage.session.get(["sessionList"], (e => { chrome.runtime.onMessage.addListener((e => {
if (e.sessionList) { if (!e || !e.type) return;
let t = e.sessionList; if ("meetingId" === e.type) meetingId = e.meetingId;
console.log(t), chrome.storage.local.get(["token"], (e => { else if ("transcriptId" === e.type) transcriptId = e.transcriptId;
if (e.token) { else if ("meetingName" === e.type) meetingName = e.meetingName;
let o = e.token; else if ("username" === e.type) username = e.data;
fetch(`${domainUrl}/api/v2/templates?quick-note=true`, { }));
method: "GET",
headers: { // در نسخه‌ی laxis این‌جا صفحه‌ی signup باز می‌شد؛ حالا عمداً خالی است.
Authorization: `Bearer ${o}`, chrome.runtime.onInstalled.addListener((() => {}));
"Content-Type": "application/json"
}
}).then((e => {
200 === e.status ? e.json().then((e => {
e.items.length ? getTopics(e.items[0].id, {
method: "GET",
headers: {
Authorization: `Bearer ${o}`,
"Content-Type": "application/json"
}
}).then((s => {
s.json().then((s => {
const n = s.items;
let r = [];
if (t.forEach((({
image: e,
person: t,
text: o,
startedAt: s,
endedAt: i,
highlight: a
}) => {
let c = [];
if (a) {
const e = bookmarkList.find((e => e.color === a[0]));
if (e) {
const t = n.find((t => t.color.toLowerCase() === e.code.toLowerCase()));
t && (c = [t.id])
}
}
o && r.push({
imageUrl: e,
person: "You" === t ? username : t,
startedAt: s,
endedAt: i || s,
highlights: c,
text: o
})
})), r.length) {
let t = JSON.stringify({
meetingId: meetingId,
meetingName: meetingName,
templateId: e.items[0].id,
transcripts: r,
isEnded: !0
}),
s = {
Authorization: `Bearer ${o}`,
"Content-Type": "application/json"
};
transcriptId ? fetch(`${domainUrl}/api/v1/speeches/google-meet/${transcriptId}`, {
method: "PUT",
headers: s,
body: t
}).then((e => {
200 === e.status ? (console.log("save success"), chrome.tabs.create({
url: `${domainUrl}/transcript/${transcriptId}`
})) : 401 === e.status ? console.log("Expired token") : e.json().then((e => {
console.log(e.message)
}))
})).catch((e => {
console.log(e)
})) : fetch(`${domainUrl}/api/v1/speeches/google-meet`, {
method: "POST",
headers: s,
body: t
}).then((e => {
200 === e.status ? (chrome.tabs.create({
url: `${domainUrl}/transcript/${transcriptId}`
}), console.log("success")) : 401 === e.status ? console.log("Expired token") : e.json().then((e => {
console.log(e.message)
}))
})).catch((e => {
console.log(e)
}))
} else console.log("Empty transcript")
}))
})).catch((e => reject(e))) : console.log("No quick note template")
})) : 401 === e.status ? console.log("Expired token") : e.json().then((e => console.log(e.message)))
}))
}
}))
}
}))
})), chrome.runtime.onMessage.addListener((e => {
console.log(e), "meetingId" === e.type && (console.log(e.meetingId), meetingId = e.meetingId), "transcriptId" === e.type && (console.log(e.transcriptId), transcriptId = e.transcriptId), "meetingName" === e.type && (meetingName = e.meetingName), "username" === e.type && (username = e.data)
})), chrome.runtime.onInstalled.addListener((e => {
// e.reason === chrome.runtime.OnInstalledReason.INSTALL && chrome.tabs.create({
// url: signupUrl
// }, (function(e) {}))
})), chrome.runtime.onMessageExternal.addListener((function(e, t, o) {
t.origin === domainUrl && (o("extension received the token"), chrome.storage.local.set({
token: e.token,
refreshToken: e.refreshToken
}, (function() {})), chrome.tabs.query({
url: googleMeetUrl
}, (function(t) {
t[0] && chrome.tabs.sendMessage(t[0].id, {
token: e.token,
refreshToken: e.refreshToken
}, (function(e) {}))
})))
})), chrome.runtime.setUninstallURL("https://www.laxis.tech/uninstall-survey");
@@ -1,9 +1,7 @@
{ {
"update_url": "https://clients2.google.com/service/update2/crx",
"manifest_version": 3, "manifest_version": 3,
"name": "Google Meet Transcripts & AI Summary", "name": "Meet Transcripts (Local Bridge)",
"description": "Google Meet Transcription, AI Summary and Insight. Get the most out of Google Meet!", "description": "Capture Google Meet captions locally and stream them to a local bridge. No cloud, no login, no tracking.",
"version": "4.3.12", "version": "4.3.12",
"icons": { "icons": {
"16": "image/logo16x16.png", "16": "image/logo16x16.png",
@@ -57,5 +55,5 @@
} }
], ],
"permissions": ["storage"], "permissions": ["storage"],
"host_permissions": ["https://meet.google.com/*"] "host_permissions": ["https://meet.google.com/*", "ws://127.0.0.1:8765/*"]
} }
+29 -1
View File
@@ -1 +1,29 @@
addRoot(),addMiniPanel(),addRemindLogin();const checkOngoingMeeting=setInterval(tryTo(addCaptionPanel,"adding button"),1e3);let notificationsTimeout;const notificationsTimeoutDuration=3e3;let checkCaptionStatusInterval;function saveLog(e){const t={message:e,time:(new Date).toISOString()};chrome.storage.local.get("logs",(function(e){var n=structuredClone(e.logs);void 0===n?n=[t]:n.push(t),chrome.storage.local.set({logs:n})}))}syncSettings(),window.addEventListener("click",(e=>{const t=document.getElementById("laxis-downloadMenu");e.target===t&&(t.style.display="none")})),chrome.runtime.onMessage.addListener((function(e,t,n){if(t.id===extensionId)if(e.token){n("token received by content script");const t={method:"GET",headers:{Authorization:`Bearer ${e.token}`}},o=()=>window.fetch(`${domainUrl}/api/v1/users/info`,t),i=()=>window.fetch(`${domainUrl}/api/v2/templates?quick-note=true`,t),s=e=>window.fetch(`${domainUrl}/api/v2/templates/${e}/topics`,t);Promise.all([o(),i()]).then((t=>{if(200===t[0].status&&200===t[1].status){loginStatus=1;const e=document.getElementById("login-prompt");e&&(e.style.display="none");const n=document.getElementById("laxis-remindLogin");n&&(n.style.display="none"),t[0].json().then((e=>{appUser=`${e.firstName} ${e.lastName}`,chrome.runtime.sendMessage({type:"username",data:`${e.firstName} ${e.lastName}`})})),t[1].json().then((e=>{let t=bookmarkList;s(e.items[0].id).then((e=>e.json().then((e=>{e.items.forEach((e=>{let n=bookmarkList.findIndex((t=>t.code===e.color));if(-1!==n){if(t[n].name=e.name,document.getElementById(`laxis-highlight-${e.color}-title`)){document.getElementById(`laxis-highlight-${e.color}-title`).innerHTML=e.name}let o=document.getElementById(`laxis-highlight-${e.color}`),i=document.getElementById(`laxis-highlight-${e.color}-mini`);o&&(o.title=`Highlight as ${e.name}`),i&&(i.title=`Highlight as ${e.name}`)}}))})))).catch((e=>console.warn(e))),bookmarkList=t}))}else 401===t[0].status||401===t[1].status?(401===t[0].status?t[0].json().then((t=>{saveLog(`chrome.runtime.onMessage.addListener 401 fail ${t.message} ${e.token}`)})):t[1].json().then((t=>{saveLog(`chrome.runtime.onMessage.addListener 401 fail ${t.message} ${e.token}`)})),reDisplayPrompt(),reDisplayRemindLogin()):t[0].json().then((e=>{window.alert(e.message)}))})).catch((e=>{window.alert(e)}))}else saveLog(`chrome.runtime.onMessage.addListener token empty fail ${e.token}`),reDisplayRemindLogin()})); // runtime.js — نقطه‌ی شروع content script (آخرین فایل لودشده).
//
// نسخه‌ی پاک‌سازی‌شده:
// - فراخوانی addRemindLogin() حذف شد (دیگر دکمه‌ی «Login to autosave» ساخته نمی‌شود).
// - listener دریافت توکن و fetch به laxis (users/info, templates) کاملاً حذف شد.
// بقیه‌ی منطق (ساخت پنل، مانیتور جلسه، تنظیمات، منوی دانلودِ محلی) دست‌نخورده است.
addRoot();
addMiniPanel();
const checkOngoingMeeting = setInterval(tryTo(addCaptionPanel, "adding button"), 1e3);
let notificationsTimeout;
const notificationsTimeoutDuration = 3e3;
let checkCaptionStatusInterval;
function saveLog(e) {
const t = { message: e, time: (new Date).toISOString() };
chrome.storage.local.get("logs", (function(e) {
var n = structuredClone(e.logs);
void 0 === n ? n = [t] : n.push(t), chrome.storage.local.set({ logs: n })
}))
}
syncSettings();
window.addEventListener("click", (e => {
const t = document.getElementById("laxis-downloadMenu");
e.target === t && (t.style.display = "none")
}));
+4
View File
@@ -0,0 +1,4 @@
.venv/
transcripts/
__pycache__/
*.pyc
+117
View File
@@ -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}` | تأیید دریافت |
+37
View File
@@ -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())
+155
View File
@@ -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👋 خاموش شد.")
+2
View File
@@ -0,0 +1,2 @@
websockets>=12.0
mcp>=1.2.0
+234
View File
@@ -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)
+79
View File
@@ -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👋 خاموش شد.")