@alook/cli 0.0.23 → 0.0.25
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +317 -38
- package/dist/meeting-runner.js +562 -0
- package/dist/session-runner.js +112 -6
- package/package.json +3 -2
|
@@ -0,0 +1,562 @@
|
|
|
1
|
+
// daemon/meeting-runner.ts
|
|
2
|
+
import { chromium } from "playwright-core";
|
|
3
|
+
|
|
4
|
+
// ../shared/src/browser/caption-scraper.ts
|
|
5
|
+
function stripHtml(html) {
|
|
6
|
+
return html.replace(/<[^>]*>/g, "").trim();
|
|
7
|
+
}
|
|
8
|
+
function parseCaptionElements(elements) {
|
|
9
|
+
const results = [];
|
|
10
|
+
for (const el of elements) {
|
|
11
|
+
const speaker = stripHtml(el.speakerHtml);
|
|
12
|
+
const text = stripHtml(el.textHtml);
|
|
13
|
+
if (!speaker || !text)
|
|
14
|
+
continue;
|
|
15
|
+
results.push({ speaker, text });
|
|
16
|
+
}
|
|
17
|
+
return results;
|
|
18
|
+
}
|
|
19
|
+
function buildCaptionObserverScript() {
|
|
20
|
+
return `
|
|
21
|
+
(() => {
|
|
22
|
+
if (window.__alookCaptionObserver) return;
|
|
23
|
+
window.__alookCaptionObserver = true;
|
|
24
|
+
window.__alookCaptions = [];
|
|
25
|
+
window.__alookLastCaption = '';
|
|
26
|
+
|
|
27
|
+
const observer = new MutationObserver(() => {
|
|
28
|
+
// On any DOM change, scan for caption content.
|
|
29
|
+
// Google Meet renders captions as overlays with img (avatar) + text.
|
|
30
|
+
// The text mutates in-place (characterData), so we snapshot on every change.
|
|
31
|
+
const imgs = document.querySelectorAll('img');
|
|
32
|
+
for (const img of imgs) {
|
|
33
|
+
let entry = img.parentElement;
|
|
34
|
+
// Walk up to find the caption entry container
|
|
35
|
+
for (let i = 0; i < 4 && entry; i++) {
|
|
36
|
+
const text = entry.textContent || '';
|
|
37
|
+
if (text.length > 3 && entry.querySelectorAll('img').length === 1) break;
|
|
38
|
+
entry = entry.parentElement;
|
|
39
|
+
}
|
|
40
|
+
if (!entry) continue;
|
|
41
|
+
|
|
42
|
+
// Check this looks like a caption (has img + non-button text)
|
|
43
|
+
const parts = [];
|
|
44
|
+
const walker = document.createTreeWalker(entry, NodeFilter.SHOW_TEXT);
|
|
45
|
+
let node;
|
|
46
|
+
while (node = walker.nextNode()) {
|
|
47
|
+
let inBtn = false;
|
|
48
|
+
let p = node.parentElement;
|
|
49
|
+
while (p && p !== entry) {
|
|
50
|
+
if (p.tagName === 'BUTTON') { inBtn = true; break; }
|
|
51
|
+
p = p.parentElement;
|
|
52
|
+
}
|
|
53
|
+
if (inBtn) continue;
|
|
54
|
+
const t = node.textContent.trim();
|
|
55
|
+
if (t.length > 0 && t.length < 500) parts.push(t);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (parts.length < 2 || parts[0].length > 40) continue;
|
|
59
|
+
|
|
60
|
+
const speaker = parts[0];
|
|
61
|
+
const text = parts.slice(1).join(' ');
|
|
62
|
+
const key = speaker + '::' + text;
|
|
63
|
+
|
|
64
|
+
// Filter out UI elements misidentified as captions
|
|
65
|
+
const lower = text.toLowerCase();
|
|
66
|
+
if (lower.includes('background') || lower.includes('effects') || lower.includes('devices') ||
|
|
67
|
+
lower.includes('more options') || lower.includes('still see your') ||
|
|
68
|
+
lower.includes('settings') || lower.includes('reframe')) continue;
|
|
69
|
+
|
|
70
|
+
// Only record if text changed from last snapshot
|
|
71
|
+
if (key !== window.__alookLastCaption) {
|
|
72
|
+
window.__alookLastCaption = key;
|
|
73
|
+
window.__alookCaptions.push({
|
|
74
|
+
speakerHtml: speaker,
|
|
75
|
+
textHtml: text,
|
|
76
|
+
ts: Date.now(),
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
observer.observe(document.body, { childList: true, subtree: true, characterData: true });
|
|
83
|
+
})()
|
|
84
|
+
`.trim();
|
|
85
|
+
}
|
|
86
|
+
function buildCaptionScrapeScript() {
|
|
87
|
+
return `
|
|
88
|
+
(() => {
|
|
89
|
+
const result = window.__alookCaptions || [];
|
|
90
|
+
window.__alookCaptions = [];
|
|
91
|
+
return result.map(c => ({ speakerHtml: c.speakerHtml, textHtml: c.textHtml }));
|
|
92
|
+
})()
|
|
93
|
+
`.trim();
|
|
94
|
+
}
|
|
95
|
+
// ../shared/src/browser/transcript.ts
|
|
96
|
+
function createTimestamp(startMs, currentMs) {
|
|
97
|
+
const elapsed = Math.max(0, currentMs - startMs);
|
|
98
|
+
const totalSeconds = Math.floor(elapsed / 1000);
|
|
99
|
+
const hours = Math.floor(totalSeconds / 3600);
|
|
100
|
+
const minutes = Math.floor(totalSeconds % 3600 / 60);
|
|
101
|
+
const seconds = totalSeconds % 60;
|
|
102
|
+
return `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`;
|
|
103
|
+
}
|
|
104
|
+
function deduplicateCaptions(existing, incoming, meetingStartMs, nowMs) {
|
|
105
|
+
const timestamp = createTimestamp(meetingStartMs, nowMs);
|
|
106
|
+
const newEntries = [];
|
|
107
|
+
for (const cap of incoming) {
|
|
108
|
+
const last = existing.length > 0 ? existing[existing.length - 1] : null;
|
|
109
|
+
if (last && last.speaker === cap.speaker && last.text === cap.text) {
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (last && last.speaker === cap.speaker && cap.text.startsWith(last.text)) {
|
|
113
|
+
last.text = cap.text;
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
newEntries.push({
|
|
117
|
+
speaker: cap.speaker,
|
|
118
|
+
text: cap.text,
|
|
119
|
+
timestamp
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
return [...existing, ...newEntries];
|
|
123
|
+
}
|
|
124
|
+
function groupIntoBlocks(entries) {
|
|
125
|
+
const blocks = [];
|
|
126
|
+
for (const entry of entries) {
|
|
127
|
+
const lastBlock = blocks.length > 0 ? blocks[blocks.length - 1] : null;
|
|
128
|
+
if (lastBlock && lastBlock.speaker === entry.speaker) {
|
|
129
|
+
lastBlock.lines.push(entry.text);
|
|
130
|
+
} else {
|
|
131
|
+
blocks.push({
|
|
132
|
+
speaker: entry.speaker,
|
|
133
|
+
lines: [entry.text],
|
|
134
|
+
startTimestamp: entry.timestamp
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return blocks;
|
|
139
|
+
}
|
|
140
|
+
function formatTranscript(entries) {
|
|
141
|
+
if (entries.length === 0)
|
|
142
|
+
return "";
|
|
143
|
+
const blocks = groupIntoBlocks(entries);
|
|
144
|
+
return blocks.map((block) => `[${block.startTimestamp}] ${block.speaker}:
|
|
145
|
+
${block.lines.join(`
|
|
146
|
+
`)}`).join(`
|
|
147
|
+
|
|
148
|
+
`);
|
|
149
|
+
}
|
|
150
|
+
// ../shared/src/browser/meet-navigator.ts
|
|
151
|
+
function delay(ms) {
|
|
152
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
153
|
+
}
|
|
154
|
+
async function dismissDialogs(page) {
|
|
155
|
+
for (let i = 0;i < 3; i++) {
|
|
156
|
+
try {
|
|
157
|
+
const dialogBtn = await page.$('[role="dialog"] button, [role="alertdialog"] button, [role="alert"] button');
|
|
158
|
+
if (dialogBtn) {
|
|
159
|
+
await dialogBtn.click();
|
|
160
|
+
await delay(300);
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
} catch {}
|
|
164
|
+
break;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
async function detectBlocked(page) {
|
|
168
|
+
const blocked = await page.evaluate(() => {
|
|
169
|
+
const text = document.body?.innerText || "";
|
|
170
|
+
if (text.includes("can't join") || text.includes("unable to join") || text.includes("无法加入")) {
|
|
171
|
+
return text.slice(0, 200);
|
|
172
|
+
}
|
|
173
|
+
return null;
|
|
174
|
+
});
|
|
175
|
+
if (blocked) {
|
|
176
|
+
throw new Error(`Blocked from joining: ${blocked}`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
async function joinMeeting(page, meetingUrl, botName) {
|
|
180
|
+
await page.goto(meetingUrl, { waitUntil: "domcontentloaded", timeout: 30000 });
|
|
181
|
+
await delay(2000);
|
|
182
|
+
await detectBlocked(page);
|
|
183
|
+
await dismissDialogs(page);
|
|
184
|
+
try {
|
|
185
|
+
const nameInput = await page.waitForSelector('input[aria-label="Your name"]', { timeout: 1e4 });
|
|
186
|
+
if (nameInput) {
|
|
187
|
+
await nameInput.click({ clickCount: 3 });
|
|
188
|
+
await nameInput.type(botName);
|
|
189
|
+
}
|
|
190
|
+
} catch {}
|
|
191
|
+
await page.evaluate(() => {
|
|
192
|
+
const btns = document.querySelectorAll("button");
|
|
193
|
+
for (const btn of btns) {
|
|
194
|
+
const label = (btn.getAttribute("aria-label") || "").toLowerCase();
|
|
195
|
+
const muted = btn.getAttribute("data-is-muted");
|
|
196
|
+
if (muted === "false" && (label.includes("microphone") || label.includes("camera"))) {
|
|
197
|
+
btn.click();
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
if (label.startsWith("turn off") && (label.includes("microphone") || label.includes("camera"))) {
|
|
201
|
+
btn.click();
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
const joined = await page.evaluate(() => {
|
|
206
|
+
const deadline = Date.now() + 15000;
|
|
207
|
+
return new Promise((resolve) => {
|
|
208
|
+
const check = () => {
|
|
209
|
+
const btns = document.querySelectorAll("button:not([disabled])");
|
|
210
|
+
for (const btn of btns) {
|
|
211
|
+
const text = (btn.textContent || "").toLowerCase();
|
|
212
|
+
if (text.includes("join") || text.includes("加入")) {
|
|
213
|
+
if (text.includes("other") || text.includes("其他"))
|
|
214
|
+
continue;
|
|
215
|
+
btn.click();
|
|
216
|
+
return resolve(true);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
if (Date.now() < deadline)
|
|
220
|
+
setTimeout(check, 500);
|
|
221
|
+
else
|
|
222
|
+
resolve(false);
|
|
223
|
+
};
|
|
224
|
+
check();
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
if (!joined)
|
|
228
|
+
throw new Error("Join button not found or remained disabled");
|
|
229
|
+
await delay(3000);
|
|
230
|
+
await detectBlocked(page);
|
|
231
|
+
}
|
|
232
|
+
async function enableCaptions(page) {
|
|
233
|
+
const clicked = await page.evaluate(() => {
|
|
234
|
+
const btns = document.querySelectorAll("button");
|
|
235
|
+
for (const btn of btns) {
|
|
236
|
+
const label = (btn.getAttribute("aria-label") || "").toLowerCase();
|
|
237
|
+
const text = (btn.textContent || "").toLowerCase();
|
|
238
|
+
if (label.includes("caption") || label.includes("subtitle") || label.includes("字幕") || text.includes("closed_caption")) {
|
|
239
|
+
btn.click();
|
|
240
|
+
return true;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return false;
|
|
244
|
+
});
|
|
245
|
+
if (clicked) {
|
|
246
|
+
await delay(2000);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
async function waitForMeetingReady(page, timeoutMs = 60000) {
|
|
250
|
+
const deadline = Date.now() + timeoutMs;
|
|
251
|
+
while (Date.now() < deadline) {
|
|
252
|
+
const state = await page.evaluate(() => {
|
|
253
|
+
const text = document.body?.innerText || "";
|
|
254
|
+
if (text.includes("Please wait") || text.includes("请等待"))
|
|
255
|
+
return "waiting";
|
|
256
|
+
if (text.includes("can't join") || text.includes("无法加入"))
|
|
257
|
+
return "blocked";
|
|
258
|
+
const inCall = document.querySelector('button[aria-label*="Leave call" i], button[aria-label="退出通话"]');
|
|
259
|
+
const waiting = document.querySelector('[aria-label*="wait" i]');
|
|
260
|
+
if (inCall && !waiting)
|
|
261
|
+
return "ready";
|
|
262
|
+
return "loading";
|
|
263
|
+
});
|
|
264
|
+
if (state === "ready")
|
|
265
|
+
return;
|
|
266
|
+
if (state === "blocked")
|
|
267
|
+
throw new Error("Blocked from joining meeting");
|
|
268
|
+
await delay(2000);
|
|
269
|
+
}
|
|
270
|
+
throw new Error("Timed out waiting to be admitted to meeting");
|
|
271
|
+
}
|
|
272
|
+
function buildAloneDetectorScript() {
|
|
273
|
+
return `
|
|
274
|
+
(() => {
|
|
275
|
+
if (window.__alookAloneDetector) return;
|
|
276
|
+
window.__alookAloneDetector = true;
|
|
277
|
+
window.__alookAlone = false;
|
|
278
|
+
|
|
279
|
+
const keywords = ['only one here', 'no one else', 'everyone has left',
|
|
280
|
+
'只有你', '没有其他人', '所有人都已离开'];
|
|
281
|
+
|
|
282
|
+
const observer = new MutationObserver((mutations) => {
|
|
283
|
+
for (const m of mutations) {
|
|
284
|
+
for (const node of m.addedNodes) {
|
|
285
|
+
if (node.nodeType !== 1) continue;
|
|
286
|
+
const text = (node.textContent || '').toLowerCase();
|
|
287
|
+
for (const kw of keywords) {
|
|
288
|
+
if (text.includes(kw)) {
|
|
289
|
+
window.__alookAlone = true;
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
});
|
|
296
|
+
observer.observe(document.body, { childList: true, subtree: true });
|
|
297
|
+
})()
|
|
298
|
+
`.trim();
|
|
299
|
+
}
|
|
300
|
+
async function isMeetingActive(page) {
|
|
301
|
+
try {
|
|
302
|
+
return await page.evaluate(() => {
|
|
303
|
+
const leaveBtn = document.querySelector('button[aria-label="Leave call" i], button[aria-label*="hang up" i]');
|
|
304
|
+
if (!leaveBtn)
|
|
305
|
+
return false;
|
|
306
|
+
if (window.__alookAlone)
|
|
307
|
+
return false;
|
|
308
|
+
return true;
|
|
309
|
+
});
|
|
310
|
+
} catch {
|
|
311
|
+
return false;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
async function leaveMeeting(page) {
|
|
315
|
+
try {
|
|
316
|
+
const leaveButton = await page.$('button[aria-label="Leave call" i], button[aria-label*="hang up" i]');
|
|
317
|
+
if (leaveButton) {
|
|
318
|
+
await leaveButton.click();
|
|
319
|
+
await delay(2000);
|
|
320
|
+
}
|
|
321
|
+
} catch {}
|
|
322
|
+
}
|
|
323
|
+
// ../shared/src/browser/chrome-finder.ts
|
|
324
|
+
import { existsSync } from "node:fs";
|
|
325
|
+
import { execSync } from "node:child_process";
|
|
326
|
+
var CHROME_PATHS = {
|
|
327
|
+
darwin: [
|
|
328
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
329
|
+
"/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
|
|
330
|
+
"/Applications/Chromium.app/Contents/MacOS/Chromium"
|
|
331
|
+
],
|
|
332
|
+
linux: [
|
|
333
|
+
"/usr/bin/google-chrome",
|
|
334
|
+
"/usr/bin/google-chrome-stable",
|
|
335
|
+
"/usr/bin/chromium-browser",
|
|
336
|
+
"/usr/bin/chromium",
|
|
337
|
+
"/snap/bin/chromium"
|
|
338
|
+
],
|
|
339
|
+
win32: [
|
|
340
|
+
"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
|
|
341
|
+
"C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe",
|
|
342
|
+
`${process.env.LOCALAPPDATA}\\Google\\Chrome\\Application\\chrome.exe`
|
|
343
|
+
]
|
|
344
|
+
};
|
|
345
|
+
function findChrome() {
|
|
346
|
+
const platform = process.platform;
|
|
347
|
+
const candidates = CHROME_PATHS[platform] ?? [];
|
|
348
|
+
for (const p of candidates) {
|
|
349
|
+
if (existsSync(p))
|
|
350
|
+
return p;
|
|
351
|
+
}
|
|
352
|
+
if (platform === "linux") {
|
|
353
|
+
try {
|
|
354
|
+
const result = execSync("which google-chrome || which chromium", { encoding: "utf8" }).trim();
|
|
355
|
+
if (result)
|
|
356
|
+
return result;
|
|
357
|
+
} catch {}
|
|
358
|
+
}
|
|
359
|
+
return findPlaywrightChromium();
|
|
360
|
+
}
|
|
361
|
+
function findPlaywrightChromium() {
|
|
362
|
+
try {
|
|
363
|
+
const result = execSync("npx playwright install --dry-run chromium 2>&1", { encoding: "utf8" });
|
|
364
|
+
const match = result.match(/browser binaries.*?:\s*(.+)/i);
|
|
365
|
+
if (match) {
|
|
366
|
+
const dir = match[1].trim();
|
|
367
|
+
const chromePaths = [
|
|
368
|
+
`${dir}/chrome-linux/chrome`,
|
|
369
|
+
`${dir}/chrome-mac/Chromium.app/Contents/MacOS/Chromium`,
|
|
370
|
+
`${dir}/chrome-win/chrome.exe`
|
|
371
|
+
];
|
|
372
|
+
for (const p of chromePaths) {
|
|
373
|
+
if (existsSync(p))
|
|
374
|
+
return p;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
} catch {}
|
|
378
|
+
return null;
|
|
379
|
+
}
|
|
380
|
+
function ensureChrome() {
|
|
381
|
+
const existing = findChrome();
|
|
382
|
+
if (existing)
|
|
383
|
+
return existing;
|
|
384
|
+
execSync("npx playwright install chromium", {
|
|
385
|
+
stdio: "inherit",
|
|
386
|
+
timeout: 120000
|
|
387
|
+
});
|
|
388
|
+
const installed = findChrome();
|
|
389
|
+
if (!installed)
|
|
390
|
+
throw new Error("Failed to install Chromium via Playwright");
|
|
391
|
+
return installed;
|
|
392
|
+
}
|
|
393
|
+
// daemon/meeting-runner.ts
|
|
394
|
+
var SCRAPE_INTERVAL_MS = 3000;
|
|
395
|
+
var BOT_NAME = "Alook Meeting Bot";
|
|
396
|
+
var MAX_RETRY_DURATION_MS = 30 * 60 * 1000;
|
|
397
|
+
var RETRY_BACKOFF = [30000, 60000, 120000, 300000];
|
|
398
|
+
function log(msg) {
|
|
399
|
+
console.log(`[meeting-runner] ${new Date().toISOString()} ${msg}`);
|
|
400
|
+
}
|
|
401
|
+
async function callbackWeb(input, status, transcript, error) {
|
|
402
|
+
const payload = JSON.stringify({
|
|
403
|
+
meetingId: input.meetingId,
|
|
404
|
+
workspaceId: input.workspaceId,
|
|
405
|
+
status,
|
|
406
|
+
transcript: transcript || undefined,
|
|
407
|
+
error: error || undefined
|
|
408
|
+
});
|
|
409
|
+
try {
|
|
410
|
+
const res = await fetch(`${input.callbackUrl}/api/meeting/callback`, {
|
|
411
|
+
method: "POST",
|
|
412
|
+
headers: {
|
|
413
|
+
"Content-Type": "application/json",
|
|
414
|
+
Authorization: `Bearer ${input.authToken}`
|
|
415
|
+
},
|
|
416
|
+
body: payload
|
|
417
|
+
});
|
|
418
|
+
log(`Callback ${status} → ${res.status}`);
|
|
419
|
+
} catch (err) {
|
|
420
|
+
log(`Callback failed: ${err instanceof Error ? err.message : err}`);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
function launchBrowser(chromePath) {
|
|
424
|
+
return chromium.launch({
|
|
425
|
+
executablePath: chromePath,
|
|
426
|
+
headless: false,
|
|
427
|
+
args: [
|
|
428
|
+
"--lang=en-US",
|
|
429
|
+
"--disable-blink-features=AutomationControlled",
|
|
430
|
+
"--use-fake-ui-for-media-stream",
|
|
431
|
+
"--use-fake-device-for-media-stream",
|
|
432
|
+
"--disable-audio-output"
|
|
433
|
+
]
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
async function tryJoinAndRecord(input, chromePath) {
|
|
437
|
+
const browser = await launchBrowser(chromePath);
|
|
438
|
+
const context = browser.contexts()[0];
|
|
439
|
+
if (context) {
|
|
440
|
+
await context.addInitScript(() => {
|
|
441
|
+
Object.defineProperty(navigator, "webdriver", { get: () => false });
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
const page = await browser.newPage({ locale: "en-US" });
|
|
445
|
+
const meetingStartMs = Date.now();
|
|
446
|
+
let transcript = [];
|
|
447
|
+
try {
|
|
448
|
+
await joinMeeting(page, input.meetingUrl, BOT_NAME);
|
|
449
|
+
log("Joined. Waiting for meeting UI...");
|
|
450
|
+
await waitForMeetingReady(page);
|
|
451
|
+
await page.evaluate(() => {
|
|
452
|
+
for (const btn of document.querySelectorAll("button")) {
|
|
453
|
+
const label = (btn.getAttribute("aria-label") || "").toLowerCase();
|
|
454
|
+
if (label.startsWith("turn off") && (label.includes("microphone") || label.includes("camera"))) {
|
|
455
|
+
btn.click();
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
});
|
|
459
|
+
log("Meeting ready. Enabling captions...");
|
|
460
|
+
await enableCaptions(page);
|
|
461
|
+
await page.evaluate(buildCaptionObserverScript());
|
|
462
|
+
await page.evaluate(buildAloneDetectorScript());
|
|
463
|
+
log("Captions enabled, observer injected. Scraping loop started.");
|
|
464
|
+
let scrapeCount = 0;
|
|
465
|
+
while (true) {
|
|
466
|
+
try {
|
|
467
|
+
const active = await isMeetingActive(page);
|
|
468
|
+
if (!active) {
|
|
469
|
+
const finalRaw = await page.evaluate(buildCaptionScrapeScript());
|
|
470
|
+
const finalCaptions = parseCaptionElements(finalRaw);
|
|
471
|
+
if (finalCaptions.length > 0) {
|
|
472
|
+
transcript = deduplicateCaptions(transcript, finalCaptions, meetingStartMs, Date.now());
|
|
473
|
+
log(`Final scrape: ${finalCaptions.length} caption(s), total ${transcript.length}`);
|
|
474
|
+
}
|
|
475
|
+
log("Meeting ended (no longer active)");
|
|
476
|
+
break;
|
|
477
|
+
}
|
|
478
|
+
const rawElements = await page.evaluate(buildCaptionScrapeScript());
|
|
479
|
+
const captions = parseCaptionElements(rawElements);
|
|
480
|
+
scrapeCount++;
|
|
481
|
+
if (captions.length > 0) {
|
|
482
|
+
const prevLen = transcript.length;
|
|
483
|
+
transcript = deduplicateCaptions(transcript, captions, meetingStartMs, Date.now());
|
|
484
|
+
if (transcript.length > prevLen) {
|
|
485
|
+
log(`Caption: ${captions[captions.length - 1].speaker}: "${captions[captions.length - 1].text}" (total ${transcript.length})`);
|
|
486
|
+
}
|
|
487
|
+
} else if (scrapeCount <= 5) {
|
|
488
|
+
log(`Scrape #${scrapeCount}: no captions yet`);
|
|
489
|
+
}
|
|
490
|
+
} catch (err) {
|
|
491
|
+
log(`Scrape error: ${err instanceof Error ? err.message : err}`);
|
|
492
|
+
break;
|
|
493
|
+
}
|
|
494
|
+
await new Promise((resolve) => setTimeout(resolve, SCRAPE_INTERVAL_MS));
|
|
495
|
+
}
|
|
496
|
+
await leaveMeeting(page);
|
|
497
|
+
return { status: "completed", transcript };
|
|
498
|
+
} catch (err) {
|
|
499
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
500
|
+
if (msg.includes("Blocked from joining")) {
|
|
501
|
+
const screenshotPath = `/tmp/meeting-${input.meetingId}-blocked.png`;
|
|
502
|
+
await page.screenshot({ path: screenshotPath }).catch(() => {});
|
|
503
|
+
log(`Blocked — screenshot: ${screenshotPath}`);
|
|
504
|
+
return { status: "blocked", transcript, error: msg };
|
|
505
|
+
}
|
|
506
|
+
log(`Error: ${msg}`);
|
|
507
|
+
return { status: "error", transcript, error: msg };
|
|
508
|
+
} finally {
|
|
509
|
+
await page.close().catch(() => {});
|
|
510
|
+
await browser.close().catch(() => {});
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
async function run(input) {
|
|
514
|
+
log(`Starting: ${input.meetingUrl} (${input.meetingId})`);
|
|
515
|
+
let chromePath;
|
|
516
|
+
try {
|
|
517
|
+
chromePath = ensureChrome();
|
|
518
|
+
log(`Chrome found: ${chromePath}`);
|
|
519
|
+
} catch (err) {
|
|
520
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
521
|
+
log(`Chrome setup failed: ${msg}`);
|
|
522
|
+
await callbackWeb(input, "failed", undefined, `Chrome setup failed: ${msg}`);
|
|
523
|
+
process.exit(1);
|
|
524
|
+
}
|
|
525
|
+
const startTime = Date.now();
|
|
526
|
+
let attempt = 0;
|
|
527
|
+
while (true) {
|
|
528
|
+
attempt++;
|
|
529
|
+
log(attempt > 1 ? `Retry attempt #${attempt}...` : "Launching browser (en-US, stealth)...");
|
|
530
|
+
const result = await tryJoinAndRecord(input, chromePath);
|
|
531
|
+
if (result.status === "completed") {
|
|
532
|
+
const transcriptText = formatTranscript(result.transcript);
|
|
533
|
+
log(`Completed: ${result.transcript.length} transcript entries`);
|
|
534
|
+
await callbackWeb(input, "completed", transcriptText);
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
if (result.status === "blocked") {
|
|
538
|
+
const elapsed = Date.now() - startTime;
|
|
539
|
+
if (elapsed >= MAX_RETRY_DURATION_MS) {
|
|
540
|
+
log(`Giving up after ${Math.round(elapsed / 60000)}min of retries`);
|
|
541
|
+
await callbackWeb(input, "failed", undefined, result.error);
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
const backoff = RETRY_BACKOFF[Math.min(attempt - 1, RETRY_BACKOFF.length - 1)];
|
|
545
|
+
log(`Blocked, retrying in ${backoff / 1000}s (attempt ${attempt}, ${Math.round(elapsed / 60000)}min elapsed)`);
|
|
546
|
+
await new Promise((resolve) => setTimeout(resolve, backoff));
|
|
547
|
+
continue;
|
|
548
|
+
}
|
|
549
|
+
await callbackWeb(input, "failed", undefined, result.error);
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
var encoded = process.argv[2];
|
|
554
|
+
if (!encoded) {
|
|
555
|
+
console.error("Usage: meeting-runner <base64-encoded-input>");
|
|
556
|
+
process.exit(1);
|
|
557
|
+
}
|
|
558
|
+
var input = JSON.parse(Buffer.from(encoded, "base64").toString("utf-8"));
|
|
559
|
+
run(input).then(() => process.exit(0)).catch((err) => {
|
|
560
|
+
log(`Fatal: ${err instanceof Error ? err.message : err}`);
|
|
561
|
+
process.exit(1);
|
|
562
|
+
});
|