@web-auto/webauto 0.1.18 → 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 +227 -12
  3. package/apps/desktop-console/dist/renderer/index.js +237 -8
  4. package/apps/desktop-console/entry/ui-cli.mjs +282 -16
  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,706 @@
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
+
14
+ import path from 'node:path';
15
+ import { spawn } from 'child_process';
16
+ import { controllerAction, delay } from '../utils/controllerAction.js';
17
+ import { resolveDownloadRoot, savePngBase64, takeScreenshotBase64 } from './helpers/evidence.js';
18
+ import { matchCommentText, CommentKeywordMatchRule, CommentMatchResult } from './helpers/commentMatcher.js';
19
+
20
+ export interface SmartReplyComment {
21
+ id: string;
22
+ visibleIndex: number;
23
+ text: string;
24
+ author: string;
25
+ parentId?: string;
26
+ }
27
+
28
+ export interface RateLimitConfig {
29
+ minIntervalMs?: number;
30
+ maxPerSession?: number;
31
+ cooldownMs?: number;
32
+ }
33
+
34
+ export interface SmartReplyInput {
35
+ sessionId: string;
36
+ noteId: string;
37
+ noteContent: string;
38
+ comments: SmartReplyComment[];
39
+ keywordRule: CommentKeywordMatchRule;
40
+ replyIntent: string;
41
+ replyStyle?: string;
42
+ maxLength?: number;
43
+ dryRun?: boolean;
44
+ unifiedApiUrl?: string;
45
+ env?: string;
46
+ keyword?: string;
47
+ rateLimit?: RateLimitConfig;
48
+ sampleMode?: boolean;
49
+ sampleCount?: number;
50
+ requireConfirmation?: boolean;
51
+ }
52
+
53
+ export interface SmartReplyOutput {
54
+ success: boolean;
55
+ matchedCount: number;
56
+ processedCount: number;
57
+ skippedCount: number;
58
+ replies: Array<{
59
+ commentId: string;
60
+ commentText: string;
61
+ author: string;
62
+ matched: boolean;
63
+ matchResult?: CommentMatchResult;
64
+ generatedReply?: string;
65
+ sent: boolean;
66
+ skipped?: boolean;
67
+ skipReason?: string;
68
+ error?: string;
69
+ evidencePath?: string;
70
+ confirmationScreenshot?: string;
71
+ replyTimeMs?: number;
72
+ }>;
73
+ evidencePaths: string[];
74
+ sessionStats: {
75
+ totalReplies: number;
76
+ sessionStartTime: number;
77
+ averageIntervalMs: number;
78
+ };
79
+ error?: string;
80
+ }
81
+
82
+ interface ClickTarget {
83
+ ok: boolean;
84
+ rect?: { x: number; y: number; width: number; height: number };
85
+ clickPoint?: { x: number; y: number };
86
+ commentRect?: { x: number; y: number; width: number; height: number };
87
+ reason?: string;
88
+ }
89
+
90
+ // Session-level rate limiting state
91
+ const sessionReplyStats = new Map<string, {
92
+ count: number;
93
+ lastReplyTime: number;
94
+ replyTimes: number[];
95
+ }>();
96
+
97
+ function getSessionStats(sessionId: string) {
98
+ if (!sessionReplyStats.has(sessionId)) {
99
+ sessionReplyStats.set(sessionId, {
100
+ count: 0,
101
+ lastReplyTime: 0,
102
+ replyTimes: [],
103
+ });
104
+ }
105
+ return sessionReplyStats.get(sessionId)!;
106
+ }
107
+
108
+ function checkRateLimit(
109
+ sessionId: string,
110
+ config: RateLimitConfig,
111
+ ): { allowed: boolean; reason?: string; waitMs?: number } {
112
+ const stats = getSessionStats(sessionId);
113
+ const now = Date.now();
114
+ const { minIntervalMs = 3000, maxPerSession = 20, cooldownMs = 60000 } = config;
115
+
116
+ if (stats.count >= maxPerSession) {
117
+ const timeSinceLast = now - stats.lastReplyTime;
118
+ if (timeSinceLast < cooldownMs) {
119
+ return {
120
+ allowed: false,
121
+ reason: `session limit reached (${maxPerSession}), cooldown ${Math.ceil((cooldownMs - timeSinceLast) / 1000)}s remaining`,
122
+ waitMs: cooldownMs - timeSinceLast,
123
+ };
124
+ }
125
+ stats.count = 0;
126
+ }
127
+
128
+ const timeSinceLast = now - stats.lastReplyTime;
129
+ if (stats.lastReplyTime > 0 && timeSinceLast < minIntervalMs) {
130
+ return {
131
+ allowed: false,
132
+ reason: `rate limit: need ${Math.ceil((minIntervalMs - timeSinceLast) / 1000)}s interval`,
133
+ waitMs: minIntervalMs - timeSinceLast,
134
+ };
135
+ }
136
+
137
+ return { allowed: true };
138
+ }
139
+
140
+ function recordReply(sessionId: string, durationMs: number) {
141
+ const stats = getSessionStats(sessionId);
142
+ stats.count++;
143
+ stats.lastReplyTime = Date.now();
144
+ stats.replyTimes.push(durationMs);
145
+ if (stats.replyTimes.length > 20) {
146
+ stats.replyTimes.shift();
147
+ }
148
+ }
149
+
150
+ async function generateReplyWithIflow(
151
+ noteContent: string,
152
+ commentText: string,
153
+ replyIntent: string,
154
+ style?: string,
155
+ maxLength?: number,
156
+ ): Promise<{ ok: boolean; reply?: string; error?: string; durationMs?: number }> {
157
+ const startTime = Date.now();
158
+ const prompt = `你是一个小红书评论回复助手。请根据以下信息生成一条回复。
159
+
160
+ ## 帖子正文
161
+ ${noteContent}
162
+
163
+ ## 命中的评论
164
+ ${commentText}
165
+
166
+ ## 回复要求
167
+ - 回复的中心意思:${replyIntent}
168
+ - 回复风格:${style || '友好、自然、口语化'}
169
+ - 字数限制:${maxLength || 100}字以内
170
+ - 不要使用表情符号开头
171
+ - 不要过于正式,保持自然对话感
172
+ - 可以适当使用 1-2 个表情符号
173
+
174
+ 请直接输出回复内容,不要有任何解释或说明。`;
175
+
176
+ return new Promise((resolve) => {
177
+ const child = spawn('iflow', ['-p', prompt], {
178
+ stdio: ['ignore', 'pipe', 'pipe'],
179
+ });
180
+
181
+ let stdout = '';
182
+ let stderr = '';
183
+
184
+ child.stdout.on('data', (chunk) => {
185
+ stdout += String(chunk);
186
+ });
187
+
188
+ child.stderr.on('data', (chunk) => {
189
+ stderr += String(chunk);
190
+ });
191
+
192
+ const timeout = setTimeout(() => {
193
+ try { child.kill('SIGTERM'); } catch {}
194
+ resolve({ ok: false, error: 'iflow timeout (30s)', durationMs: Date.now() - startTime });
195
+ }, 30000);
196
+
197
+ child.on('error', (err) => {
198
+ clearTimeout(timeout);
199
+ resolve({ ok: false, error: err.message, durationMs: Date.now() - startTime });
200
+ });
201
+
202
+ child.on('close', (code) => {
203
+ clearTimeout(timeout);
204
+ const durationMs = Date.now() - startTime;
205
+
206
+ if (code !== 0) {
207
+ resolve({ ok: false, error: stderr || `iflow exit code ${code}`, durationMs });
208
+ return;
209
+ }
210
+
211
+ const lines = stdout.trim().split('\n');
212
+ let replyText = '';
213
+
214
+ for (let i = lines.length - 1; i >= 0; i--) {
215
+ const line = lines[i].trim();
216
+ if (line.startsWith('{') && line.endsWith('}')) {
217
+ try {
218
+ const info = JSON.parse(line);
219
+ if (info.tokenUsage || info['conversation-id']) {
220
+ replyText = lines.slice(0, i).join('\n').trim();
221
+ break;
222
+ }
223
+ } catch {}
224
+ }
225
+ }
226
+
227
+ if (!replyText) {
228
+ replyText = stdout.trim();
229
+ const execInfoMatch = replyText.match(/<Execution Info>[\s\S]*$/);
230
+ if (execInfoMatch) {
231
+ replyText = replyText.slice(0, execInfoMatch.index).trim();
232
+ }
233
+ }
234
+
235
+ replyText = replyText.replace(/^["']|["']$/g, '').replace(/\n+/g, ' ').trim();
236
+
237
+ if (!replyText) {
238
+ resolve({ ok: false, error: 'empty reply from iflow', durationMs });
239
+ return;
240
+ }
241
+
242
+ resolve({ ok: true, reply: replyText, durationMs });
243
+ });
244
+ });
245
+ }
246
+
247
+ async function findReplyButtonInComment(
248
+ sessionId: string,
249
+ apiUrl: string,
250
+ commentVisibleIndex: number,
251
+ ): Promise<ClickTarget> {
252
+ const res = await controllerAction(
253
+ 'browser:execute',
254
+ {
255
+ profile: sessionId,
256
+ script: `(() => {
257
+ const idx = ${JSON.stringify(commentVisibleIndex)};
258
+ const isVisible = (el) => {
259
+ const r = el.getBoundingClientRect();
260
+ return r.width > 0 && r.height > 0 && r.bottom > 0 && r.top < window.innerHeight && r.right > 0 && r.left < window.innerWidth;
261
+ };
262
+
263
+ const items = Array.from(document.querySelectorAll('.comment-item')).filter(isVisible);
264
+ const root = items[idx];
265
+ if (!root) return { ok: false, reason: 'comment-not-found', total: items.length };
266
+
267
+ const commentRect = root.getBoundingClientRect();
268
+
269
+ const textEq = (el, s) => (el && (el.textContent || '').replace(/\\s+/g,' ').trim() === s);
270
+ const candidates = Array.from(root.querySelectorAll('span, button, a, div'));
271
+
272
+ let raw = candidates.find(el => textEq(el, '回复')) || null;
273
+
274
+ if (!raw) {
275
+ const actionArea = root.querySelector('.comment-actions, .actions, [class*="action"]');
276
+ if (actionArea) {
277
+ raw = Array.from(actionArea.querySelectorAll('span, button, a')).find(el =>
278
+ textEq(el, '回复') || el.getAttribute('data-type') === 'reply'
279
+ ) || null;
280
+ }
281
+ }
282
+
283
+ 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) } };
284
+
285
+ const target = raw.closest && (raw.closest('button, a, [role="button"]') || raw) || raw;
286
+ const r = target.getBoundingClientRect();
287
+ 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) } };
288
+
289
+ const mx = Math.round(r.left + r.width / 2);
290
+ const my = Math.round(r.top + r.height / 2);
291
+
292
+ return {
293
+ ok: true,
294
+ rect: { x: Math.round(r.left), y: Math.round(r.top), width: Math.round(r.width), height: Math.round(r.height) },
295
+ clickPoint: { x: mx, y: my },
296
+ commentRect: { x: Math.round(commentRect.left), y: Math.round(commentRect.top), width: Math.round(commentRect.width), height: Math.round(commentRect.height) }
297
+ };
298
+ })()`,
299
+ },
300
+ apiUrl,
301
+ );
302
+ return (res?.result || res?.data?.result || res) as ClickTarget;
303
+ }
304
+
305
+ async function drawReplyOverlay(
306
+ sessionId: string,
307
+ apiUrl: string,
308
+ opts: {
309
+ id: string;
310
+ commentRect?: { x: number; y: number; width: number; height: number };
311
+ buttonRect?: { x: number; y: number; width: number; height: number };
312
+ color: string;
313
+ label?: string;
314
+ ttlMs?: number;
315
+ dryRun?: boolean;
316
+ },
317
+ ): Promise<boolean | null> {
318
+ const ttlMs = opts.ttlMs || 8000;
319
+ const result = await controllerAction(
320
+ 'browser:execute',
321
+ {
322
+ profile: sessionId,
323
+ script: `(() => {
324
+ const id = ${JSON.stringify(opts.id)};
325
+ const commentRect = ${JSON.stringify(opts.commentRect || null)};
326
+ const buttonRect = ${JSON.stringify(opts.buttonRect || null)};
327
+ const color = ${JSON.stringify(opts.color)};
328
+ const label = ${JSON.stringify(opts.label || '')};
329
+ const ttl = ${JSON.stringify(ttlMs)};
330
+ const dryRun = ${JSON.stringify(opts.dryRun || false)};
331
+
332
+ const ensure = (elId, baseStyle) => {
333
+ let el = document.getElementById(elId);
334
+ if (!el) {
335
+ el = document.createElement('div');
336
+ el.id = elId;
337
+ document.body.appendChild(el);
338
+ }
339
+ Object.assign(el.style, baseStyle);
340
+ return el;
341
+ };
342
+
343
+ if (commentRect) {
344
+ ensure(id + '-comment', {
345
+ position: 'fixed',
346
+ left: commentRect.x + 'px',
347
+ top: commentRect.y + 'px',
348
+ width: commentRect.width + 'px',
349
+ height: commentRect.height + 'px',
350
+ border: '4px solid ' + color,
351
+ boxSizing: 'border-box',
352
+ zIndex: '2147483646',
353
+ pointerEvents: 'none',
354
+ background: color + '10',
355
+ boxShadow: '0 0 20px ' + color + '40',
356
+ });
357
+ }
358
+
359
+ if (buttonRect) {
360
+ ensure(id + '-button', {
361
+ position: 'fixed',
362
+ left: buttonRect.x + 'px',
363
+ top: buttonRect.y + 'px',
364
+ width: buttonRect.width + 'px',
365
+ height: buttonRect.height + 'px',
366
+ border: '3px solid #ff0066',
367
+ borderRadius: '4px',
368
+ boxSizing: 'border-box',
369
+ zIndex: '2147483647',
370
+ pointerEvents: 'none',
371
+ background: 'rgba(255, 0, 102, 0.2)',
372
+ });
373
+ }
374
+
375
+ if (label) {
376
+ const labelEl = ensure(id + '-label', {
377
+ position: 'fixed',
378
+ left: '12px',
379
+ top: '12px',
380
+ maxWidth: '80vw',
381
+ padding: '12px 16px',
382
+ background: dryRun ? 'rgba(0, 100, 255, 0.95)' : 'rgba(0, 150, 50, 0.95)',
383
+ color: '#fff',
384
+ fontSize: '13px',
385
+ lineHeight: '1.5',
386
+ borderRadius: '8px',
387
+ zIndex: '2147483648',
388
+ pointerEvents: 'none',
389
+ whiteSpace: 'pre-wrap',
390
+ boxShadow: '0 4px 20px rgba(0,0,0,0.4)',
391
+ fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
392
+ });
393
+ labelEl.textContent = label;
394
+ }
395
+
396
+ setTimeout(() => {
397
+ try {
398
+ ['-comment', '-button', '-label'].forEach(suffix => {
399
+ const el = document.getElementById(id + suffix);
400
+ if (el && el.parentElement) el.parentElement.removeChild(el);
401
+ });
402
+ } catch {}
403
+ }, ttl);
404
+
405
+ return true;
406
+ })()`,
407
+ },
408
+ apiUrl,
409
+ ).catch((): null => null);
410
+ return result?.result || result?.data?.result || result;
411
+ }
412
+
413
+ async function sendReply(
414
+ sessionId: string,
415
+ noteId: string,
416
+ commentVisibleIndex: number,
417
+ replyText: string,
418
+ apiUrl: string,
419
+ isDryRun: boolean,
420
+ ): Promise<{ ok: boolean; submitted: boolean; error?: string; durationMs: number }> {
421
+ const startTime = Date.now();
422
+ const { execute } = await import('./ReplyInteractBlock.js');
423
+
424
+ const result = await execute({
425
+ sessionId,
426
+ noteId,
427
+ commentVisibleIndex,
428
+ replyText,
429
+ dryRun: isDryRun,
430
+ unifiedApiUrl: apiUrl,
431
+ dev: false,
432
+ });
433
+
434
+ const submitted = isDryRun ? false : result.submitted === true;
435
+ const ok = result.success === true && (isDryRun ? true : submitted);
436
+
437
+ return {
438
+ ok,
439
+ submitted,
440
+ error: result.error,
441
+ durationMs: Date.now() - startTime,
442
+ };
443
+ }
444
+
445
+ async function takeConfirmationScreenshot(
446
+ sessionId: string,
447
+ apiUrl: string,
448
+ outDir: string,
449
+ commentId: string,
450
+ prefix: string,
451
+ ): Promise<string | undefined> {
452
+ await delay(500);
453
+ const base64 = await takeScreenshotBase64(sessionId, apiUrl);
454
+ if (!base64) return undefined;
455
+
456
+ const name = `${prefix}-confirm-${commentId}-${Date.now()}.png`;
457
+ return await savePngBase64(base64, path.join(outDir, name));
458
+ }
459
+
460
+ export async function execute(input: SmartReplyInput): Promise<SmartReplyOutput> {
461
+ const {
462
+ sessionId,
463
+ noteId,
464
+ noteContent,
465
+ comments,
466
+ keywordRule,
467
+ replyIntent,
468
+ replyStyle,
469
+ maxLength,
470
+ dryRun = false,
471
+ unifiedApiUrl = 'http://127.0.0.1:7701',
472
+ env = 'debug',
473
+ keyword = 'unknown',
474
+ rateLimit = {},
475
+ sampleMode = false,
476
+ sampleCount = 3,
477
+ requireConfirmation = false,
478
+ } = input;
479
+
480
+ const replies: SmartReplyOutput['replies'] = [];
481
+ const evidencePaths: string[] = [];
482
+ const sessionStartTime = Date.now();
483
+
484
+ try {
485
+ const matchedComments = comments
486
+ .map((c) => ({ comment: c, match: matchCommentText(c.text, keywordRule) }))
487
+ .filter(({ match }) => match.ok)
488
+ .map(({ comment, match }) => ({ ...comment, matchResult: match }));
489
+
490
+ if (matchedComments.length === 0) {
491
+ return {
492
+ success: true,
493
+ matchedCount: 0,
494
+ processedCount: 0,
495
+ skippedCount: 0,
496
+ replies: [],
497
+ evidencePaths: [],
498
+ sessionStats: {
499
+ totalReplies: 0,
500
+ sessionStartTime,
501
+ averageIntervalMs: 0,
502
+ },
503
+ };
504
+ }
505
+
506
+ const targetComments = sampleMode
507
+ ? matchedComments.slice(0, sampleCount)
508
+ : matchedComments;
509
+
510
+ let processedCount = 0;
511
+ let skippedCount = 0;
512
+
513
+ for (const comment of targetComments) {
514
+ const rateCheck = checkRateLimit(sessionId, rateLimit);
515
+ if (!rateCheck.allowed) {
516
+ replies.push({
517
+ commentId: comment.id,
518
+ commentText: comment.text,
519
+ author: comment.author,
520
+ matched: true,
521
+ matchResult: comment.matchResult,
522
+ sent: false,
523
+ skipped: true,
524
+ skipReason: rateCheck.reason,
525
+ });
526
+ skippedCount++;
527
+
528
+ if (rateCheck.waitMs && rateCheck.waitMs < 10000) {
529
+ await delay(rateCheck.waitMs);
530
+ }
531
+ continue;
532
+ }
533
+
534
+ const buttonTarget = await findReplyButtonInComment(
535
+ sessionId,
536
+ unifiedApiUrl,
537
+ comment.visibleIndex,
538
+ );
539
+
540
+ if (!buttonTarget.ok) {
541
+ replies.push({
542
+ commentId: comment.id,
543
+ commentText: comment.text,
544
+ author: comment.author,
545
+ matched: true,
546
+ matchResult: comment.matchResult,
547
+ sent: false,
548
+ error: `reply button not found: ${buttonTarget.reason}`,
549
+ });
550
+ continue;
551
+ }
552
+
553
+ const genResult = await generateReplyWithIflow(
554
+ noteContent,
555
+ comment.text,
556
+ replyIntent,
557
+ replyStyle,
558
+ maxLength,
559
+ );
560
+
561
+ if (!genResult.ok) {
562
+ replies.push({
563
+ commentId: comment.id,
564
+ commentText: comment.text,
565
+ author: comment.author,
566
+ matched: true,
567
+ matchResult: comment.matchResult,
568
+ sent: false,
569
+ error: genResult.error,
570
+ });
571
+ continue;
572
+ }
573
+
574
+ const generatedReply = genResult.reply!;
575
+
576
+ const outDir = path.join(
577
+ resolveDownloadRoot(),
578
+ 'xiaohongshu',
579
+ env,
580
+ keyword,
581
+ 'smart-reply',
582
+ noteId,
583
+ );
584
+
585
+ await drawReplyOverlay(sessionId, unifiedApiUrl, {
586
+ id: `smart-reply-${comment.id}`,
587
+ commentRect: buttonTarget.commentRect,
588
+ buttonRect: buttonTarget.rect,
589
+ color: '#00e5ff',
590
+ label: dryRun
591
+ ? `[DRYRUN] 样本验证\n评论: ${comment.text.slice(0, 40)}...\n作者: ${comment.author}\nAI回复: ${generatedReply.slice(0, 50)}...`
592
+ : `[REPLY] 目标确认\n评论: ${comment.text.slice(0, 40)}...\n作者: ${comment.author}\nAI回复: ${generatedReply.slice(0, 50)}...`,
593
+ ttlMs: 10000,
594
+ dryRun,
595
+ });
596
+ await delay(600);
597
+
598
+ let confirmationScreenshot: string | undefined;
599
+ if (requireConfirmation || dryRun) {
600
+ confirmationScreenshot = await takeConfirmationScreenshot(
601
+ sessionId,
602
+ unifiedApiUrl,
603
+ outDir,
604
+ comment.id,
605
+ dryRun ? 'dryrun' : 'target',
606
+ );
607
+ if (confirmationScreenshot) {
608
+ evidencePaths.push(confirmationScreenshot);
609
+ }
610
+
611
+ if (requireConfirmation && !dryRun) {
612
+ await delay(2000);
613
+ }
614
+ }
615
+
616
+ let sent = false;
617
+ let sendError: string | undefined;
618
+ let replyDurationMs = 0;
619
+
620
+ if (!dryRun) {
621
+ const sendResult = await sendReply(
622
+ sessionId,
623
+ noteId,
624
+ comment.visibleIndex,
625
+ generatedReply,
626
+ unifiedApiUrl,
627
+ false,
628
+ );
629
+ sent = sendResult.submitted;
630
+ sendError = sendResult.error || (sendResult.submitted ? undefined : 'reply not submitted');
631
+ replyDurationMs = sendResult.durationMs;
632
+
633
+ if (sendResult.submitted) {
634
+ recordReply(sessionId, replyDurationMs);
635
+ processedCount++;
636
+ }
637
+ } else {
638
+ await delay(1000);
639
+ sent = false;
640
+ processedCount++;
641
+ }
642
+
643
+ replies.push({
644
+ commentId: comment.id,
645
+ commentText: comment.text,
646
+ author: comment.author,
647
+ matched: true,
648
+ matchResult: comment.matchResult,
649
+ generatedReply,
650
+ sent: dryRun ? false : sent,
651
+ error: sendError,
652
+ confirmationScreenshot,
653
+ replyTimeMs: replyDurationMs,
654
+ });
655
+
656
+ if (sent || dryRun) {
657
+ await delay(500);
658
+ const finalBase64 = await takeScreenshotBase64(sessionId, unifiedApiUrl);
659
+ if (finalBase64) {
660
+ const name = `reply-${dryRun ? 'dryrun' : 'sent'}-final-${comment.id}-${Date.now()}.png`;
661
+ const finalPath = await savePngBase64(finalBase64, path.join(outDir, name));
662
+ if (finalPath) {
663
+ evidencePaths.push(finalPath);
664
+ }
665
+ }
666
+ }
667
+
668
+ const intervalMs = rateLimit.minIntervalMs || 3000;
669
+ await delay(intervalMs);
670
+ }
671
+
672
+ const stats = getSessionStats(sessionId);
673
+ const avgInterval = stats.replyTimes.length > 0
674
+ ? stats.replyTimes.reduce((a, b) => a + b, 0) / stats.replyTimes.length
675
+ : 0;
676
+
677
+ return {
678
+ success: true,
679
+ matchedCount: matchedComments.length,
680
+ processedCount,
681
+ skippedCount,
682
+ replies,
683
+ evidencePaths,
684
+ sessionStats: {
685
+ totalReplies: stats.count,
686
+ sessionStartTime,
687
+ averageIntervalMs: Math.round(avgInterval),
688
+ },
689
+ };
690
+ } catch (e: any) {
691
+ return {
692
+ success: false,
693
+ matchedCount: replies.filter(r => r.matched).length,
694
+ processedCount: replies.filter(r => !r.skipped).length,
695
+ skippedCount: replies.filter(r => r.skipped).length,
696
+ replies,
697
+ evidencePaths,
698
+ sessionStats: {
699
+ totalReplies: getSessionStats(sessionId).count,
700
+ sessionStartTime,
701
+ averageIntervalMs: 0,
702
+ },
703
+ error: e?.message || String(e),
704
+ };
705
+ }
706
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@web-auto/webauto",
3
- "version": "0.1.18",
3
+ "version": "0.1.19",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "webauto": "bin/webauto.mjs"
@@ -37,7 +37,7 @@
37
37
  "!apps/desktop-console/dist/**/*.map"
38
38
  ],
39
39
  "dependencies": {
40
- "@web-auto/camo": "^0.1.13",
40
+ "@web-auto/camo": "^0.1.17",
41
41
  "ajv": "^8.18.0",
42
42
  "esbuild-register": "^3.6.0",
43
43
  "iconv-lite": "^0.6.3",