3 Commits

Author SHA1 Message Date
Arash_M 04b340d742 Merge branch 'main' of http://194.180.11.201:3000/arash/audio-voice-converter 2026-06-16 10:22:26 +03:30
Arash_M e2e005e2ff planing 2026-06-16 10:22:21 +03:30
vahidaskari 7bc34c79ed 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.
2026-06-12 00:31:32 +03:30
40 changed files with 1070 additions and 844 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
+1
View File
@@ -2,6 +2,7 @@
- [ ] برای لوگو یک لوگو از خودمون میذاریم.
- [ ] مثلا لاگین یا اگر داده‌ای به جایی میفرسته نباید بفرسته اینا هم اضافی هستن
- [ ] هدف نهایی: بتونیم با استفاده از این پلاگین کپشن رو بگیریم
- [ ] راهکارها:
- [ ] پلاگین خودش یک mcp سرویس بده روی یک پورت
+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";
const i = document.createElement("img");
i.src = chrome.runtime.getURL("image/logo.png"), i.addEventListener("click", (function() {
window.open(`${domainUrl}/login`)
})), 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);
void 0 /* لینک لاگین laxis حذف شد */
})), 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");
o.style.display = "flex", o.style.alignItems = "center";
const d = document.createElement("div");
@@ -42,7 +42,7 @@ const addTutorialPanel = () => {
const g = createCaptionOnIcon();
y.appendChild(g), u.appendChild(y), s.appendChild(u);
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");
x.style.paddingTop = "32px", x.innerHTML = "Download", s.appendChild(x);
let C = document.createElement("div");
@@ -52,7 +52,7 @@ const addTutorialPanel = () => {
let E = createDownloadIcon();
v.appendChild(E), C.appendChild(v), s.appendChild(C);
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");
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");
l.src = chrome.runtime.getURL("image/logo.png"), l.addEventListener("click", (function() {
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);
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);
const i = document.createElement("div");
i.title = "Expand", i.id = "laxis-expandPanel", i.classList.add("miniButtonContainer"), i.addEventListener("click", addTutorialPanel);
const o = createExpandIcon();
@@ -112,8 +110,8 @@ const addCaptionPanel = () => {
p.style.display = "flex", p.style.alignItems = "center", p.style.paddingLeft = "8px";
const m = document.createElement("img");
m.src = chrome.runtime.getURL("image/logo.png"), m.alt = "none", m.addEventListener("click", (function() {
window.open(`${domainUrl}/login`)
})), m.style.height = "30px", m.style.marginLeft = "-16px", m.style.cursor = "pointer", m.style.zIndex = "999", p.appendChild(m), r.appendChild(p);
void 0 /* لینک لاگین laxis حذف شد */
})), 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");
u.style.display = "flex", u.style.alignItems = "center";
const y = document.createElement("div");
@@ -144,7 +142,6 @@ const addCaptionPanel = () => {
const I = document.createElement("div");
I.style.width = "300px", I.style.backgroundColor = "#292c35", I.style.paddingTop = "8px";
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");
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");
@@ -192,13 +189,11 @@ const addCaptionPanel = () => {
n.className = "modal-header", n.innerHTML = "Download", t.appendChild(n);
let l = 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");
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");
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");
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");
s.className = "modal-footer";
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")
},
addRemindLogin = () => {
console.log("add login");
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)
// غیرفعال شد: دکمه‌ی «Login to autosave» مربوط به laxis cloud بود.
},
reDisplayRemindLogin = () => {
console.log("redisplay");
const e = document.getElementById("laxis-remindLogin");
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) => {
// غیرفعال شد: آپلود transcript به laxis cloud حذف شده است. هیچ داده‌ای بیرون نمی‌رود.
return n(new Error("Laxis cloud disabled"));
chrome.storage.local.get(["token"], (function(i) {
if (i.token) {
let s = document.getElementById("laxis-openDownloadMenu"),
@@ -260,10 +256,10 @@ Export2App = async (e, t = !1) => new Promise(((o, n) => {
a = () => Export2Txt(s, e);
break;
default:
a = () => Export2App(o)
a = () => Export2Txt(s, e)
}
a().then((e => {
"app" === i && window.open(`${domainUrl}/transcript/${e}`, "_blank")
a().then((() => {
// قبلاً برای گزینه‌ی «app» صفحه‌ی laxis cloud باز می‌شد؛ حذف شد.
})).catch((e => {
window.alert(e);
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 {
importScripts("config/share.js"), importScripts("config/panel.js")
importScripts("config/share.js"), importScripts("config/panel.js"), importScripts("bridge.js")
} catch (e) {
console.error(e)
}
let meetingId = null,
transcriptId = null,
meetingName = null,
username = "";
chrome.storage.session.setAccessLevel({
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 => {
if (e.sessionList) {
let t = e.sessionList;
console.log(t), chrome.storage.local.get(["token"], (e => {
if (e.token) {
let o = e.token;
fetch(`${domainUrl}/api/v2/templates?quick-note=true`, {
method: "GET",
headers: {
Authorization: `Bearer ${o}`,
"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");
// فقط نگه‌داشتن وضعیتِ لوکال؛ هیچ درخواست شبکه‌ای انجام نمی‌شود.
chrome.runtime.onMessage.addListener((e => {
if (!e || !e.type) return;
if ("meetingId" === e.type) meetingId = e.meetingId;
else if ("transcriptId" === e.type) transcriptId = e.transcriptId;
else if ("meetingName" === e.type) meetingName = e.meetingName;
else if ("username" === e.type) username = e.data;
}));
// در نسخه‌ی laxis این‌جا صفحه‌ی signup باز می‌شد؛ حالا عمداً خالی است.
chrome.runtime.onInstalled.addListener((() => {}));
@@ -1,9 +1,7 @@
{
"update_url": "https://clients2.google.com/service/update2/crx",
"manifest_version": 3,
"name": "Google Meet Transcripts & AI Summary",
"description": "Google Meet Transcription, AI Summary and Insight. Get the most out of Google Meet!",
"name": "Meet Transcripts (Local Bridge)",
"description": "Capture Google Meet captions locally and stream them to a local bridge. No cloud, no login, no tracking.",
"version": "4.3.12",
"icons": {
"16": "image/logo16x16.png",
@@ -57,5 +55,5 @@
}
],
"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👋 خاموش شد.")
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
File diff suppressed because one or more lines are too long