@web-auto/webauto 0.1.17 → 0.1.19

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.
Files changed (65) hide show
  1. package/README.md +122 -53
  2. package/apps/desktop-console/dist/main/index.mjs +229 -14
  3. package/apps/desktop-console/dist/renderer/index.js +237 -8
  4. package/apps/desktop-console/entry/ui-cli.mjs +290 -21
  5. package/apps/desktop-console/entry/ui-console.mjs +46 -15
  6. package/apps/webauto/entry/account.mjs +126 -27
  7. package/apps/webauto/entry/lib/account-detect.mjs +399 -9
  8. package/apps/webauto/entry/lib/account-store.mjs +201 -109
  9. package/apps/webauto/entry/lib/iflow-reply.mjs +194 -0
  10. package/apps/webauto/entry/lib/profile-policy.mjs +48 -0
  11. package/apps/webauto/entry/lib/profilepool.mjs +12 -0
  12. package/apps/webauto/entry/lib/schedule-store.mjs +29 -2
  13. package/apps/webauto/entry/lib/session-init.mjs +227 -0
  14. package/apps/webauto/entry/lib/upgrade-check.mjs +269 -0
  15. package/apps/webauto/entry/lib/xhs-unified-blocks.mjs +160 -0
  16. package/apps/webauto/entry/lib/xhs-unified-output-blocks.mjs +83 -0
  17. package/apps/webauto/entry/lib/xhs-unified-plan-blocks.mjs +55 -0
  18. package/apps/webauto/entry/lib/xhs-unified-profile-blocks.mjs +542 -0
  19. package/apps/webauto/entry/lib/xhs-unified-runtime-blocks.mjs +436 -0
  20. package/apps/webauto/entry/profilepool.mjs +56 -9
  21. package/apps/webauto/entry/smart-reply-cli.mjs +267 -0
  22. package/apps/webauto/entry/weibo-unified.mjs +84 -11
  23. package/apps/webauto/entry/xhs-orchestrate.mjs +43 -1
  24. package/apps/webauto/entry/xhs-unified.mjs +92 -997
  25. package/bin/webauto.mjs +22 -4
  26. package/dist/modules/camo-backend/src/index.js +33 -0
  27. package/dist/modules/camo-backend/src/internal/BrowserSession.js +232 -49
  28. package/dist/modules/camo-backend/src/internal/engine-manager.js +14 -13
  29. package/dist/modules/camo-backend/src/internal/ws-server.js +16 -19
  30. package/dist/modules/camo-runtime/src/utils/browser-service.mjs +38 -6
  31. package/dist/modules/workflow/blocks/EnsureSession.js +0 -8
  32. package/dist/modules/workflow/blocks/WeiboCollectFromLinksBlock.js +78 -6
  33. package/dist/modules/workflow/blocks/WeiboCollectSearchLinksBlock.js +266 -192
  34. package/dist/modules/workflow/definitions/weibo-search-workflow-v1.js +2 -0
  35. package/dist/modules/workflow/src/runner.js +2 -0
  36. package/dist/modules/xiaohongshu/app/src/blocks/ReplyInteractBlock.js +150 -37
  37. package/dist/modules/xiaohongshu/app/src/blocks/SmartReplyBlock.js +491 -0
  38. package/modules/camo-backend/src/index.ts +31 -0
  39. package/modules/camo-backend/src/internal/BrowserSession.ts +224 -53
  40. package/modules/camo-backend/src/internal/engine-manager.ts +14 -15
  41. package/modules/camo-backend/src/internal/ws-server.ts +17 -17
  42. package/modules/camo-runtime/src/autoscript/action-providers/xhs/common.mjs +12 -2
  43. package/modules/camo-runtime/src/autoscript/action-providers/xhs/persistence.mjs +57 -0
  44. package/modules/camo-runtime/src/autoscript/action-providers/xhs.mjs +2475 -243
  45. package/modules/camo-runtime/src/autoscript/runtime.mjs +35 -30
  46. package/modules/camo-runtime/src/autoscript/xhs-unified-template.mjs +80 -443
  47. package/modules/camo-runtime/src/container/runtime-core/checkpoint.mjs +39 -6
  48. package/modules/camo-runtime/src/container/runtime-core/operations/index.mjs +206 -39
  49. package/modules/camo-runtime/src/container/runtime-core/operations/tab-pool.mjs +0 -79
  50. package/modules/camo-runtime/src/container/runtime-core/operations/viewport.mjs +46 -0
  51. package/modules/camo-runtime/src/utils/browser-service.mjs +41 -6
  52. package/modules/camo-runtime/src/utils/js-policy.mjs +28 -0
  53. package/modules/workflow/blocks/EnsureSession.ts +0 -4
  54. package/modules/workflow/blocks/WeiboCollectFromLinksBlock.ts +81 -6
  55. package/modules/workflow/blocks/WeiboCollectSearchLinksBlock.ts +316 -0
  56. package/modules/workflow/definitions/weibo-search-workflow-v1.ts +2 -0
  57. package/modules/workflow/src/runner.ts +2 -0
  58. package/modules/xiaohongshu/app/src/blocks/ReplyInteractBlock.ts +198 -53
  59. package/modules/xiaohongshu/app/src/blocks/SmartReplyBlock.ts +706 -0
  60. package/package.json +2 -2
  61. package/modules/camo-runtime/src/autoscript/action-providers/xhs/comments.mjs +0 -498
  62. package/modules/camo-runtime/src/autoscript/action-providers/xhs/detail.mjs +0 -181
  63. package/modules/camo-runtime/src/autoscript/action-providers/xhs/interaction.mjs +0 -691
  64. package/modules/camo-runtime/src/autoscript/action-providers/xhs/search.mjs +0 -388
  65. package/modules/camo-runtime/src/container/runtime-core/operations/selector-scripts.mjs +0 -135
@@ -0,0 +1,491 @@
1
+ /**
2
+ * Block: SmartReplyBlock
3
+ *
4
+ * 职责:
5
+ * 1. 根据关键词规则筛选命中评论
6
+ * 2. 使用 iflow -p 生成智能回复
7
+ * 3. 支持 dryRun 模式(全流程但不发送)
8
+ * 4. 生成回复模拟截图(高亮命中评论 + 覆盖层显示回复内容)
9
+ * 5. 支持真实回复发送
10
+ * 6. 评论级别流控(避免触发风控)
11
+ * 7. 评论元素内精确回复(不回复到别的楼)
12
+ */
13
+ import path from 'node:path';
14
+ import { spawn } from 'child_process';
15
+ import { controllerAction, delay } from '../utils/controllerAction.js';
16
+ import { resolveDownloadRoot, savePngBase64, takeScreenshotBase64 } from './helpers/evidence.js';
17
+ import { matchCommentText } from './helpers/commentMatcher.js';
18
+ // Session-level rate limiting state
19
+ const sessionReplyStats = new Map();
20
+ function getSessionStats(sessionId) {
21
+ if (!sessionReplyStats.has(sessionId)) {
22
+ sessionReplyStats.set(sessionId, {
23
+ count: 0,
24
+ lastReplyTime: 0,
25
+ replyTimes: [],
26
+ });
27
+ }
28
+ return sessionReplyStats.get(sessionId);
29
+ }
30
+ function checkRateLimit(sessionId, config) {
31
+ const stats = getSessionStats(sessionId);
32
+ const now = Date.now();
33
+ const { minIntervalMs = 3000, maxPerSession = 20, cooldownMs = 60000 } = config;
34
+ if (stats.count >= maxPerSession) {
35
+ const timeSinceLast = now - stats.lastReplyTime;
36
+ if (timeSinceLast < cooldownMs) {
37
+ return {
38
+ allowed: false,
39
+ reason: `session limit reached (${maxPerSession}), cooldown ${Math.ceil((cooldownMs - timeSinceLast) / 1000)}s remaining`,
40
+ waitMs: cooldownMs - timeSinceLast,
41
+ };
42
+ }
43
+ stats.count = 0;
44
+ }
45
+ const timeSinceLast = now - stats.lastReplyTime;
46
+ if (stats.lastReplyTime > 0 && timeSinceLast < minIntervalMs) {
47
+ return {
48
+ allowed: false,
49
+ reason: `rate limit: need ${Math.ceil((minIntervalMs - timeSinceLast) / 1000)}s interval`,
50
+ waitMs: minIntervalMs - timeSinceLast,
51
+ };
52
+ }
53
+ return { allowed: true };
54
+ }
55
+ function recordReply(sessionId, durationMs) {
56
+ const stats = getSessionStats(sessionId);
57
+ stats.count++;
58
+ stats.lastReplyTime = Date.now();
59
+ stats.replyTimes.push(durationMs);
60
+ if (stats.replyTimes.length > 20) {
61
+ stats.replyTimes.shift();
62
+ }
63
+ }
64
+ async function generateReplyWithIflow(noteContent, commentText, replyIntent, style, maxLength) {
65
+ const startTime = Date.now();
66
+ const prompt = `你是一个小红书评论回复助手。请根据以下信息生成一条回复。
67
+
68
+ ## 帖子正文
69
+ ${noteContent}
70
+
71
+ ## 命中的评论
72
+ ${commentText}
73
+
74
+ ## 回复要求
75
+ - 回复的中心意思:${replyIntent}
76
+ - 回复风格:${style || '友好、自然、口语化'}
77
+ - 字数限制:${maxLength || 100}字以内
78
+ - 不要使用表情符号开头
79
+ - 不要过于正式,保持自然对话感
80
+ - 可以适当使用 1-2 个表情符号
81
+
82
+ 请直接输出回复内容,不要有任何解释或说明。`;
83
+ return new Promise((resolve) => {
84
+ const child = spawn('iflow', ['-p', prompt], {
85
+ stdio: ['ignore', 'pipe', 'pipe'],
86
+ });
87
+ let stdout = '';
88
+ let stderr = '';
89
+ child.stdout.on('data', (chunk) => {
90
+ stdout += String(chunk);
91
+ });
92
+ child.stderr.on('data', (chunk) => {
93
+ stderr += String(chunk);
94
+ });
95
+ const timeout = setTimeout(() => {
96
+ try {
97
+ child.kill('SIGTERM');
98
+ }
99
+ catch { }
100
+ resolve({ ok: false, error: 'iflow timeout (30s)', durationMs: Date.now() - startTime });
101
+ }, 30000);
102
+ child.on('error', (err) => {
103
+ clearTimeout(timeout);
104
+ resolve({ ok: false, error: err.message, durationMs: Date.now() - startTime });
105
+ });
106
+ child.on('close', (code) => {
107
+ clearTimeout(timeout);
108
+ const durationMs = Date.now() - startTime;
109
+ if (code !== 0) {
110
+ resolve({ ok: false, error: stderr || `iflow exit code ${code}`, durationMs });
111
+ return;
112
+ }
113
+ const lines = stdout.trim().split('\n');
114
+ let replyText = '';
115
+ for (let i = lines.length - 1; i >= 0; i--) {
116
+ const line = lines[i].trim();
117
+ if (line.startsWith('{') && line.endsWith('}')) {
118
+ try {
119
+ const info = JSON.parse(line);
120
+ if (info.tokenUsage || info['conversation-id']) {
121
+ replyText = lines.slice(0, i).join('\n').trim();
122
+ break;
123
+ }
124
+ }
125
+ catch { }
126
+ }
127
+ }
128
+ if (!replyText) {
129
+ replyText = stdout.trim();
130
+ const execInfoMatch = replyText.match(/<Execution Info>[\s\S]*$/);
131
+ if (execInfoMatch) {
132
+ replyText = replyText.slice(0, execInfoMatch.index).trim();
133
+ }
134
+ }
135
+ replyText = replyText.replace(/^["']|["']$/g, '').replace(/\n+/g, ' ').trim();
136
+ if (!replyText) {
137
+ resolve({ ok: false, error: 'empty reply from iflow', durationMs });
138
+ return;
139
+ }
140
+ resolve({ ok: true, reply: replyText, durationMs });
141
+ });
142
+ });
143
+ }
144
+ async function findReplyButtonInComment(sessionId, apiUrl, commentVisibleIndex) {
145
+ const res = await controllerAction('browser:execute', {
146
+ profile: sessionId,
147
+ script: `(() => {
148
+ const idx = ${JSON.stringify(commentVisibleIndex)};
149
+ const isVisible = (el) => {
150
+ const r = el.getBoundingClientRect();
151
+ return r.width > 0 && r.height > 0 && r.bottom > 0 && r.top < window.innerHeight && r.right > 0 && r.left < window.innerWidth;
152
+ };
153
+
154
+ const items = Array.from(document.querySelectorAll('.comment-item')).filter(isVisible);
155
+ const root = items[idx];
156
+ if (!root) return { ok: false, reason: 'comment-not-found', total: items.length };
157
+
158
+ const commentRect = root.getBoundingClientRect();
159
+
160
+ const textEq = (el, s) => (el && (el.textContent || '').replace(/\\s+/g,' ').trim() === s);
161
+ const candidates = Array.from(root.querySelectorAll('span, button, a, div'));
162
+
163
+ let raw = candidates.find(el => textEq(el, '回复')) || null;
164
+
165
+ if (!raw) {
166
+ const actionArea = root.querySelector('.comment-actions, .actions, [class*="action"]');
167
+ if (actionArea) {
168
+ raw = Array.from(actionArea.querySelectorAll('span, button, a')).find(el =>
169
+ textEq(el, '回复') || el.getAttribute('data-type') === 'reply'
170
+ ) || null;
171
+ }
172
+ }
173
+
174
+ if (!raw) return { ok: false, reason: 'reply-button-not-found', commentRect: { x: Math.round(commentRect.left), y: Math.round(commentRect.top), width: Math.round(commentRect.width), height: Math.round(commentRect.height) } };
175
+
176
+ const target = raw.closest && (raw.closest('button, a, [role="button"]') || raw) || raw;
177
+ const r = target.getBoundingClientRect();
178
+ if (!r || !r.width || !r.height) return { ok: false, reason: 'reply-rect-empty', commentRect: { x: Math.round(commentRect.left), y: Math.round(commentRect.top), width: Math.round(commentRect.width), height: Math.round(commentRect.height) } };
179
+
180
+ const mx = Math.round(r.left + r.width / 2);
181
+ const my = Math.round(r.top + r.height / 2);
182
+
183
+ return {
184
+ ok: true,
185
+ rect: { x: Math.round(r.left), y: Math.round(r.top), width: Math.round(r.width), height: Math.round(r.height) },
186
+ clickPoint: { x: mx, y: my },
187
+ commentRect: { x: Math.round(commentRect.left), y: Math.round(commentRect.top), width: Math.round(commentRect.width), height: Math.round(commentRect.height) }
188
+ };
189
+ })()`,
190
+ }, apiUrl);
191
+ return (res?.result || res?.data?.result || res);
192
+ }
193
+ async function drawReplyOverlay(sessionId, apiUrl, opts) {
194
+ const ttlMs = opts.ttlMs || 8000;
195
+ const result = await controllerAction('browser:execute', {
196
+ profile: sessionId,
197
+ script: `(() => {
198
+ const id = ${JSON.stringify(opts.id)};
199
+ const commentRect = ${JSON.stringify(opts.commentRect || null)};
200
+ const buttonRect = ${JSON.stringify(opts.buttonRect || null)};
201
+ const color = ${JSON.stringify(opts.color)};
202
+ const label = ${JSON.stringify(opts.label || '')};
203
+ const ttl = ${JSON.stringify(ttlMs)};
204
+ const dryRun = ${JSON.stringify(opts.dryRun || false)};
205
+
206
+ const ensure = (elId, baseStyle) => {
207
+ let el = document.getElementById(elId);
208
+ if (!el) {
209
+ el = document.createElement('div');
210
+ el.id = elId;
211
+ document.body.appendChild(el);
212
+ }
213
+ Object.assign(el.style, baseStyle);
214
+ return el;
215
+ };
216
+
217
+ if (commentRect) {
218
+ ensure(id + '-comment', {
219
+ position: 'fixed',
220
+ left: commentRect.x + 'px',
221
+ top: commentRect.y + 'px',
222
+ width: commentRect.width + 'px',
223
+ height: commentRect.height + 'px',
224
+ border: '4px solid ' + color,
225
+ boxSizing: 'border-box',
226
+ zIndex: '2147483646',
227
+ pointerEvents: 'none',
228
+ background: color + '10',
229
+ boxShadow: '0 0 20px ' + color + '40',
230
+ });
231
+ }
232
+
233
+ if (buttonRect) {
234
+ ensure(id + '-button', {
235
+ position: 'fixed',
236
+ left: buttonRect.x + 'px',
237
+ top: buttonRect.y + 'px',
238
+ width: buttonRect.width + 'px',
239
+ height: buttonRect.height + 'px',
240
+ border: '3px solid #ff0066',
241
+ borderRadius: '4px',
242
+ boxSizing: 'border-box',
243
+ zIndex: '2147483647',
244
+ pointerEvents: 'none',
245
+ background: 'rgba(255, 0, 102, 0.2)',
246
+ });
247
+ }
248
+
249
+ if (label) {
250
+ const labelEl = ensure(id + '-label', {
251
+ position: 'fixed',
252
+ left: '12px',
253
+ top: '12px',
254
+ maxWidth: '80vw',
255
+ padding: '12px 16px',
256
+ background: dryRun ? 'rgba(0, 100, 255, 0.95)' : 'rgba(0, 150, 50, 0.95)',
257
+ color: '#fff',
258
+ fontSize: '13px',
259
+ lineHeight: '1.5',
260
+ borderRadius: '8px',
261
+ zIndex: '2147483648',
262
+ pointerEvents: 'none',
263
+ whiteSpace: 'pre-wrap',
264
+ boxShadow: '0 4px 20px rgba(0,0,0,0.4)',
265
+ fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
266
+ });
267
+ labelEl.textContent = label;
268
+ }
269
+
270
+ setTimeout(() => {
271
+ try {
272
+ ['-comment', '-button', '-label'].forEach(suffix => {
273
+ const el = document.getElementById(id + suffix);
274
+ if (el && el.parentElement) el.parentElement.removeChild(el);
275
+ });
276
+ } catch {}
277
+ }, ttl);
278
+
279
+ return true;
280
+ })()`,
281
+ }, apiUrl).catch(() => null);
282
+ return result?.result || result?.data?.result || result;
283
+ }
284
+ async function sendReply(sessionId, noteId, commentVisibleIndex, replyText, apiUrl, isDryRun) {
285
+ const startTime = Date.now();
286
+ const { execute } = await import('./ReplyInteractBlock.js');
287
+ const result = await execute({
288
+ sessionId,
289
+ noteId,
290
+ commentVisibleIndex,
291
+ replyText,
292
+ dryRun: isDryRun,
293
+ unifiedApiUrl: apiUrl,
294
+ dev: false,
295
+ });
296
+ const submitted = isDryRun ? false : result.submitted === true;
297
+ const ok = result.success === true && (isDryRun ? true : submitted);
298
+ return {
299
+ ok,
300
+ submitted,
301
+ error: result.error,
302
+ durationMs: Date.now() - startTime,
303
+ };
304
+ }
305
+ async function takeConfirmationScreenshot(sessionId, apiUrl, outDir, commentId, prefix) {
306
+ await delay(500);
307
+ const base64 = await takeScreenshotBase64(sessionId, apiUrl);
308
+ if (!base64)
309
+ return undefined;
310
+ const name = `${prefix}-confirm-${commentId}-${Date.now()}.png`;
311
+ return await savePngBase64(base64, path.join(outDir, name));
312
+ }
313
+ export async function execute(input) {
314
+ const { sessionId, noteId, noteContent, comments, keywordRule, replyIntent, replyStyle, maxLength, dryRun = false, unifiedApiUrl = 'http://127.0.0.1:7701', env = 'debug', keyword = 'unknown', rateLimit = {}, sampleMode = false, sampleCount = 3, requireConfirmation = false, } = input;
315
+ const replies = [];
316
+ const evidencePaths = [];
317
+ const sessionStartTime = Date.now();
318
+ try {
319
+ const matchedComments = comments
320
+ .map((c) => ({ comment: c, match: matchCommentText(c.text, keywordRule) }))
321
+ .filter(({ match }) => match.ok)
322
+ .map(({ comment, match }) => ({ ...comment, matchResult: match }));
323
+ if (matchedComments.length === 0) {
324
+ return {
325
+ success: true,
326
+ matchedCount: 0,
327
+ processedCount: 0,
328
+ skippedCount: 0,
329
+ replies: [],
330
+ evidencePaths: [],
331
+ sessionStats: {
332
+ totalReplies: 0,
333
+ sessionStartTime,
334
+ averageIntervalMs: 0,
335
+ },
336
+ };
337
+ }
338
+ const targetComments = sampleMode
339
+ ? matchedComments.slice(0, sampleCount)
340
+ : matchedComments;
341
+ let processedCount = 0;
342
+ let skippedCount = 0;
343
+ for (const comment of targetComments) {
344
+ const rateCheck = checkRateLimit(sessionId, rateLimit);
345
+ if (!rateCheck.allowed) {
346
+ replies.push({
347
+ commentId: comment.id,
348
+ commentText: comment.text,
349
+ author: comment.author,
350
+ matched: true,
351
+ matchResult: comment.matchResult,
352
+ sent: false,
353
+ skipped: true,
354
+ skipReason: rateCheck.reason,
355
+ });
356
+ skippedCount++;
357
+ if (rateCheck.waitMs && rateCheck.waitMs < 10000) {
358
+ await delay(rateCheck.waitMs);
359
+ }
360
+ continue;
361
+ }
362
+ const buttonTarget = await findReplyButtonInComment(sessionId, unifiedApiUrl, comment.visibleIndex);
363
+ if (!buttonTarget.ok) {
364
+ replies.push({
365
+ commentId: comment.id,
366
+ commentText: comment.text,
367
+ author: comment.author,
368
+ matched: true,
369
+ matchResult: comment.matchResult,
370
+ sent: false,
371
+ error: `reply button not found: ${buttonTarget.reason}`,
372
+ });
373
+ continue;
374
+ }
375
+ const genResult = await generateReplyWithIflow(noteContent, comment.text, replyIntent, replyStyle, maxLength);
376
+ if (!genResult.ok) {
377
+ replies.push({
378
+ commentId: comment.id,
379
+ commentText: comment.text,
380
+ author: comment.author,
381
+ matched: true,
382
+ matchResult: comment.matchResult,
383
+ sent: false,
384
+ error: genResult.error,
385
+ });
386
+ continue;
387
+ }
388
+ const generatedReply = genResult.reply;
389
+ const outDir = path.join(resolveDownloadRoot(), 'xiaohongshu', env, keyword, 'smart-reply', noteId);
390
+ await drawReplyOverlay(sessionId, unifiedApiUrl, {
391
+ id: `smart-reply-${comment.id}`,
392
+ commentRect: buttonTarget.commentRect,
393
+ buttonRect: buttonTarget.rect,
394
+ color: '#00e5ff',
395
+ label: dryRun
396
+ ? `[DRYRUN] 样本验证\n评论: ${comment.text.slice(0, 40)}...\n作者: ${comment.author}\nAI回复: ${generatedReply.slice(0, 50)}...`
397
+ : `[REPLY] 目标确认\n评论: ${comment.text.slice(0, 40)}...\n作者: ${comment.author}\nAI回复: ${generatedReply.slice(0, 50)}...`,
398
+ ttlMs: 10000,
399
+ dryRun,
400
+ });
401
+ await delay(600);
402
+ let confirmationScreenshot;
403
+ if (requireConfirmation || dryRun) {
404
+ confirmationScreenshot = await takeConfirmationScreenshot(sessionId, unifiedApiUrl, outDir, comment.id, dryRun ? 'dryrun' : 'target');
405
+ if (confirmationScreenshot) {
406
+ evidencePaths.push(confirmationScreenshot);
407
+ }
408
+ if (requireConfirmation && !dryRun) {
409
+ await delay(2000);
410
+ }
411
+ }
412
+ let sent = false;
413
+ let sendError;
414
+ let replyDurationMs = 0;
415
+ if (!dryRun) {
416
+ const sendResult = await sendReply(sessionId, noteId, comment.visibleIndex, generatedReply, unifiedApiUrl, false);
417
+ sent = sendResult.submitted;
418
+ sendError = sendResult.error || (sendResult.submitted ? undefined : 'reply not submitted');
419
+ replyDurationMs = sendResult.durationMs;
420
+ if (sendResult.submitted) {
421
+ recordReply(sessionId, replyDurationMs);
422
+ processedCount++;
423
+ }
424
+ }
425
+ else {
426
+ await delay(1000);
427
+ sent = false;
428
+ processedCount++;
429
+ }
430
+ replies.push({
431
+ commentId: comment.id,
432
+ commentText: comment.text,
433
+ author: comment.author,
434
+ matched: true,
435
+ matchResult: comment.matchResult,
436
+ generatedReply,
437
+ sent: dryRun ? false : sent,
438
+ error: sendError,
439
+ confirmationScreenshot,
440
+ replyTimeMs: replyDurationMs,
441
+ });
442
+ if (sent || dryRun) {
443
+ await delay(500);
444
+ const finalBase64 = await takeScreenshotBase64(sessionId, unifiedApiUrl);
445
+ if (finalBase64) {
446
+ const name = `reply-${dryRun ? 'dryrun' : 'sent'}-final-${comment.id}-${Date.now()}.png`;
447
+ const finalPath = await savePngBase64(finalBase64, path.join(outDir, name));
448
+ if (finalPath) {
449
+ evidencePaths.push(finalPath);
450
+ }
451
+ }
452
+ }
453
+ const intervalMs = rateLimit.minIntervalMs || 3000;
454
+ await delay(intervalMs);
455
+ }
456
+ const stats = getSessionStats(sessionId);
457
+ const avgInterval = stats.replyTimes.length > 0
458
+ ? stats.replyTimes.reduce((a, b) => a + b, 0) / stats.replyTimes.length
459
+ : 0;
460
+ return {
461
+ success: true,
462
+ matchedCount: matchedComments.length,
463
+ processedCount,
464
+ skippedCount,
465
+ replies,
466
+ evidencePaths,
467
+ sessionStats: {
468
+ totalReplies: stats.count,
469
+ sessionStartTime,
470
+ averageIntervalMs: Math.round(avgInterval),
471
+ },
472
+ };
473
+ }
474
+ catch (e) {
475
+ return {
476
+ success: false,
477
+ matchedCount: replies.filter(r => r.matched).length,
478
+ processedCount: replies.filter(r => !r.skipped).length,
479
+ skippedCount: replies.filter(r => r.skipped).length,
480
+ replies,
481
+ evidencePaths,
482
+ sessionStats: {
483
+ totalReplies: getSessionStats(sessionId).count,
484
+ sessionStartTime,
485
+ averageIntervalMs: 0,
486
+ },
487
+ error: e?.message || String(e),
488
+ };
489
+ }
490
+ }
491
+ //# sourceMappingURL=SmartReplyBlock.js.map
@@ -34,6 +34,12 @@ function readNumber(value: string | undefined): number | null {
34
34
  return parsed;
35
35
  }
36
36
 
37
+ function readPositiveNumber(value: any): number | null {
38
+ const parsed = Number(value);
39
+ if (!Number.isFinite(parsed) || parsed <= 0) return null;
40
+ return parsed;
41
+ }
42
+
37
43
  function getDisplayMetrics() {
38
44
  const envWidth = readNumber(process.env.WEBAUTO_SCREEN_WIDTH);
39
45
  const envHeight = readNumber(process.env.WEBAUTO_SCREEN_HEIGHT);
@@ -141,6 +147,29 @@ function getDisplayMetrics() {
141
147
  }
142
148
  }
143
149
 
150
+ function resolveStartViewport(args: any): { width: number; height: number } | null {
151
+ const explicitWidth = readPositiveNumber(args?.width ?? args?.viewportWidth ?? args?.viewport?.width);
152
+ const explicitHeight = readPositiveNumber(args?.height ?? args?.viewportHeight ?? args?.viewport?.height);
153
+ if (explicitWidth && explicitHeight) {
154
+ return {
155
+ width: Math.floor(explicitWidth),
156
+ height: Math.floor(explicitHeight),
157
+ };
158
+ }
159
+ if (Boolean(args?.headless)) return null;
160
+ const display = getDisplayMetrics();
161
+ if (!display) return null;
162
+ const reserveRaw = Number(process.env.WEBAUTO_WINDOW_VERTICAL_RESERVE ?? 0);
163
+ const reserve = Number.isFinite(reserveRaw) ? Math.max(0, Math.min(240, Math.floor(reserveRaw))) : 0;
164
+ const baseWidth = readPositiveNumber((display as any).workWidth) || readPositiveNumber(display.width);
165
+ const baseHeight = readPositiveNumber((display as any).workHeight) || readPositiveNumber(display.height);
166
+ if (!baseWidth || !baseHeight) return null;
167
+ return {
168
+ width: Math.max(960, Math.floor(baseWidth)),
169
+ height: Math.max(700, Math.floor(baseHeight - reserve)),
170
+ };
171
+ }
172
+
144
173
  export async function startBrowserService(opts: BrowserServiceOptions = {}) {
145
174
  const { logEvent } = installServiceProcessLogger({ serviceName: 'browser-service' });
146
175
  const host = opts.host || '127.0.0.1';
@@ -298,6 +327,7 @@ async function handleCommand(
298
327
 
299
328
  switch (action) {
300
329
  case 'start': {
330
+ const startViewport = resolveStartViewport(args);
301
331
  const opts: CreateSessionPayload = {
302
332
  profileId: args.profileId || 'default',
303
333
  sessionName: args.profileId || 'default',
@@ -305,6 +335,7 @@ async function handleCommand(
305
335
  initialUrl: args.url,
306
336
  engine: args.engine || 'camoufox',
307
337
  fingerprintPlatform: args.fingerprintPlatform || null,
338
+ ...(startViewport ? { viewport: startViewport } : {}),
308
339
  ...(args.ownerPid ? { ownerPid: args.ownerPid } : {}),
309
340
  };
310
341
  const res = await manager.createSession(opts);