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